Фикстуры в Symfony + Codeception DataFactory

28 нояб. 2021 г.

Всем привет. Сегодня хотел бы поделиться способом организации фикстур в Symfony приложении с помощью Codeception Doctrine2 модуля.

Фикстуры используются для генерации тестовых данных. Чаще всего, для тестовые данные бывают нужны для интеграционных тестов. В Symfony фикстуры реализованы в рамках DoctrineFixturesBundle.

Фикстура может выглядеть примерно так:

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

Такой подход работает, однако, возможно несколько проблем.

  • Функциональных тестов может быть много.
  • Одна фикстура может использоваться в нескольких тестах - теряется гибкость.
  • Для каждого теста создается своя фикстура. Получаем очень много похожего кода.
  • Код фикстур похожий, но не полностью повторяющийся. Его придется поддерживать раздельно.
  • Для корректной работы приложения могут понадобиться связанные сущности. Их придется также создавать вручную.

В результате создание и поддержка фикстур может оказаться довольно утомительным занятием. Для более удобной организации фикстур можно использовать DataFactory из пакета Codeception. При этом необязательно использовать полностью весь фреймворк.

Зависимости

Для работы фикстур нужно установить DoctrineFixturesBundle.

composer require doctrine/doctrine-fixtures-bundle

Также нам понадобится модуль Doctrine2 для Codeception.

composer require --dev codeception/module-doctrine2

Для генерирования случайных данных может пригодиться Faker.

composer require fzaninotto/faker

Правда, в конце 2020 автор прекратил разработку пакета. Поэтому, возможно, стоит рассмотреть альтернативные варианты или форк. Или вовсе отказаться от случайных данных в фикстурах.

Кастомный код

Cоздадим класс AbstractFunctionalTest и определим в нем логику взаимодействия с фикстурами. В дальнейшем от этого класса будут наследоваться все функциональные тесты.

Затем создадим класс DataFactory и методы для создания кастомных сущностей. Для создания каждой сущности создается отдельный метод. Сначала создается массив значений по-умолчанию, затем этот массив объединяется с массивом параметров. Таким образом, в фикстуре можно указать только необходимые для конкретного теста значения. Также по-умолчанию создаются связанные сущности.

<?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 можно использовать в фикстурах таким образом:

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

Конфигурации фикстур

Также, для избежания повторения кода в фикстурах, можно создавать конфигурацию фикстуры. Создается массив с определением параметров создаваемых сущностей. Само создание сущностей происходит в родительском классе. Пример реализации: FeatureControllerFixture, BaseFixture. Однако, в этом случае важно сохранять баланс и видеть границу между удобством и гибкостью.

Итог

Данный подход к организации фикстур использовал в нескольких проектах. В результате создание фикстур и написание функциональных тестов значительно упрощалось. Пример реализации и использования доступен в сервисе feature-флагов: https://github.com/antonshell/feature-flags.

На этом пока все. Спасибо за внимание!