Symfony Fixtures with Codeception DataFactory

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:

  • A lot of integration tests in project.
  • One fixture is used for multiple tests, then we loose flexibility.
  • Each test has iwn fixture, then there is a lot of similar code.
  • Fixtures have similar but not fully duplicated code. So, all fixtures have to be maintained separately
  • There are related entities in project, which can be created manually

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.

Dependencies

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.

Custom code

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);
    }
}

Fixtures configurations

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.

Conclusion

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!