28 Nov 2021
Hello everyone! Today I would like to describe the method of fixtures organisation in Symfony based web-application with Codeception Doctrine2 module.
Usually fixtures are used for testing data generation. Most common case usages are integration tests. Symfony's fixtures are implemented within DoctrineFixturesBundle package.
Fixture may look like that:
// src/DataFixtures/AppFixtures.php
namespace App\DataFixtures;
use App\Entity\Product;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class AppFixtures extends Fixture
{
public function load(ObjectManager $manager)
{
// create 20 products! Bam!
for ($i = 0; $i < 20; $i++) {
$product = new Product();
$product->setName('product '.$i);
$product->setPrice(mt_rand(10, 100));
$manager->persist($product);
}
$manager->flush();
}
}
Such approach works good, but there are several possible issues:
Finally, fixtures creating and maintaining can become tricky process. It turned into a real problem for one of projects that I was working on Then we decided to use some part of Codeception for more convenient structure of fixtures. We also decided not to add entire codeception package as a dependency, because we needed only small part of it.
There is an option to use DataFactory from Codeception for more convenient structure of fixtures. It can be used with PHPUnit without entire codeception package.
DoctrineFixturesBundle is required for fixtures itself.
composer require doctrine/doctrine-fixtures-bundle
Doctrine2 Codeception module is required for data factory.
composer require --dev codeception/module-doctrine2
Faker is optional and can be used for random data generation.
composer require fzaninotto/faker
However, maintainer abandoned package at the end of 2020. Then I would consider alternatives or fork. Or probably even avoid random values in fixtures.
Created AbstractFunctionalTest class, which defines interaction between tests and fixtures. Then all functional tests extend this class.
Implemented DataFactory class and methods for creating entities. There is a separate method for creating each entity. Default data array merged with custom parameters array. So, it's possible to specify only necessary fields in fixture. There are also related entities created by default.
<?php
declare(strict_types=1);
namespace App\Tests\Factory;
use App\Entity\Environment;
use App\Entity\Feature;
use App\Entity\FeatureValue;
use App\Entity\Project;
use Codeception\Lib\Di;
use Codeception\Lib\ModuleContainer;
use Codeception\Module\Doctrine2;
use Doctrine\ORM\EntityManager;
use Faker\Factory;
use Faker\Generator;
class DataFactory
{
protected EntityManager $entityManager;
private Doctrine2 $doctrine;
protected Generator $faker;
public function __construct(
EntityManager $entityManager
) {
$this->entityManager = $entityManager;
$this->faker = Factory::create();
$this->initializeDoctrineModule();
}
public function createProject(array $data = []): Project
{
$data = $this->setDefaultValues($data, [
'name' => 'demo',
'description' => 'demo project',
'owner' => 'antonshell',
'readKey' => bin2hex(random_bytes(64)),
'manageKey' => bin2hex(random_bytes(64)),
]);
$data['readKey'] = password_hash($data['readKey'], PASSWORD_BCRYPT);
$data['manageKey'] = password_hash($data['manageKey'], PASSWORD_BCRYPT);
return $this->doctrine->grabEntityFromRepository(Project::class, [
'id' => $this->doctrine->haveInRepository(Project::class, $data),
]);
}
public function createEnvironment(array $data = []): Environment
{
$data = $this->setDefaultValues($data, [
'name' => 'prod',
'description' => 'Production environment',
]);
if (!array_key_exists('project', $data)) {
$project = $this->createProject();
$data['project'] = $project;
$data['project_id'] = $project->getId();
}
return $this->doctrine->grabEntityFromRepository(Environment::class, [
'id' => $this->doctrine->haveInRepository(Environment::class, $data),
]);
}
protected function setDefaultValues(array $data, array $defaults): array
{
return array_merge($defaults, $data);
}
private function initializeDoctrineModule(): void
{
$di = new Di();
$moduleContainer = new ModuleContainer($di, []);
$this->doctrine = new Doctrine2($moduleContainer);
$reflectionProperty = new \ReflectionProperty(Doctrine2::class, 'em');
$reflectionProperty->setAccessible(true);
$reflectionProperty->setValue($this->doctrine, $this->entityManager);
}
}
DataFactory should be used in fixtures like that:
<?php
declare(strict_types=1);
namespace App\Tests\DataFixtures\Repository;
use App\Tests\Factory\DataFactory;
use App\Tests\DataFixtures\BaseFixture;
use Doctrine\Persistence\ObjectManager;
class SomeFixture extends BaseFixture
{
public function load(ObjectManager $objectManager): void
{
$factory = new DataFactory($objectManager);
$project = $factory->createProject([
'name' => 'Test project',
'owner' => 'antonshell',
'readKey' => 'read_key',
]);
$this->addReference('test_project_ref', $project);
}
}
It also may be helpful to create fixtures configuration arrays. It would help to avoid duplicated code in fixtures. It would be an array that defines parameters of all entities. Creation of entities happens in parent class. There is an example: FeatureControllerFixture, BaseFixture. However, it's important to find a balance between convenient and flexibility.
I used this approach for structuring fixtures in several projects. Then creating and maintaining integration tests and fixtures became simpler and more comfortable. This approach is implemented in feature-flags service: https://github.com/antonshell/feature-flags.
That's all for today. Thank you for your attention!