- Запускаем контейнеры командой
docker-compose up -d
- Входим в контейнер командой
docker exec -it php sh
. Дальнейшие команды будем выполнять из контейнера - Устанавливаем зависимости командой
composer install
- Выполняем миграции командой
php bin/console doctrine:migrations:migrate
- Устанавливаем пакеты
codeception/codeception
,codeception/module-symfony
,codeception/module-doctrine2
,codeception/module-asserts
, в dev-режиме - Переходим в браузере по адресу
http://localhost:7777/chain
, видим ответ сервисаApp\ServiceChainService
- Удаляем файл
tests/acceptance.suite.yml
- Добавляем в файле
tests/functional.suite.yml
в секциюmodules.enabled
модульAsserts
- Выполняем команду
vendor/bin/codecept build
- Добавляем тест
App\Tests\functional\ChainServiceCest
<?php namespace App\Tests\functional; use App\Service\Chain\ChainService; use App\Service\Chain\Processor\FinalPlainProcessor; use App\Service\Chain\Processor\FinalProcessor; use App\Service\Chain\Processor\SimplePlainProcessor; use App\Service\Chain\Processor\SimpleProcessor; use App\Tests\FunctionalTester; class ChainServiceCest { public function testProcess(FunctionalTester $I): void { $chainService = new ChainService( new SimplePlainProcessor(), new FinalPlainProcessor(), new SimpleProcessor(), new FinalProcessor(), ); $expectedMessage = 'Start'. ' -> simple plain processor'. ' -> final plain processor'. ' -> simple processor'. ' -> final processor'. ' -> Finish'; $I->assertSame($expectedMessage, $chainService->process()); } }
- Запускаем тест командой
vendor/bin/codecept run functional
, видим, что тест проходит
- Исправляем тест
App\Tests\functional\ChainServiceCest
<?php namespace App\Tests\functional; use App\Service\Chain\ChainService; use App\Service\Chain\Processor\FinalPlainProcessor; use App\Service\Chain\Processor\FinalProcessor; use App\Service\Chain\Processor\SimplePlainProcessor; use App\Service\Chain\Processor\SimpleProcessor; use App\Tests\FunctionalTester; use Codeception\Stub; use Exception; class ChainServiceCest { /** * @throws Exception */ public function testProcess(FunctionalTester $I): void { $simplePlainProcessorMock = Stub::make(SimplePlainProcessor::class, ['process' => 'test simple plain processor']); $chainService = new ChainService( $simplePlainProcessorMock, new FinalPlainProcessor(), new SimpleProcessor(), new FinalProcessor(), ); $expectedMessage = 'test simple plain processor'. ' -> final plain processor'. ' -> simple processor'. ' -> final processor'. ' -> Finish'; $I->assertSame($expectedMessage, $chainService->process()); } }
- Запускаем тест командой
vendor/bin/codecept run functional
, видим ошибку - Фиксируем версию
phpunit
командойcomposer require phpunit/phpunit:10.2.7 --dev
- Запускаем тест командой
vendor/bin/codecept run functional
, видим, что тест проходит
- Исправляем тест
App\Tests\functional\ChainServiceCest
<?php namespace App\Tests\functional; use App\Service\Chain\ChainService; use App\Tests\FunctionalTester; use Exception; class ChainServiceCest { /** * @throws Exception */ public function testProcess(FunctionalTester $I): void { $chainService = $I->grabService(ChainService::class); $expectedMessage = 'test simple plain processor'. ' -> final plain processor'. ' -> simple processor'. ' -> final processor'. ' -> Finish'; $I->assertSame($expectedMessage, $chainService->process()); } }
- Запускаем тест командой
vendor/bin/codecept run functional
, тест ожидаемо падает - В файл
config/services
добавляем новую секциюwhen@test: services: App\Service\Chain\Processor\SimplePlainProcessor: factory: ['Codeception\Stub', 'make'] arguments: - App\Service\Chain\Processor\SimplePlainProcessor - { process: 'test simple plain processor' }
- Запускаем тест командой
vendor/bin/codecept run functional
, тест проходит
- В файле
config/services
исправляем секцию[email protected]
App\Service\Chain\Processor\FinalPlainProcessor: factory: ['Codeception\Stub', 'make'] arguments: - App\Service\Chain\Processor\FinalPlainProcessor - { process: 'test final plain processor' }
- Запускаем тест командой
vendor/bin/codecept run functional
, видим ошибку, что нельзя сделать двойник финального класса
- В файле
config/services
исправляем секцию[email protected]
App\Service\Chain\Processor\SimpleProcessorInterface: '@TestSimpleProcessor' TestSimpleProcessor: class: App\Service\Chain\Processor\SimpleProcessor factory: ['Codeception\Stub', 'make'] arguments: - App\Service\Chain\Processor\SimpleProcessor - { process: 'test simple processor' }
- Исправляем тест
App\Tests\functional\ChainServiceCest
<?php namespace App\Tests\functional; use App\Service\Chain\ChainService; use App\Tests\FunctionalTester; use Exception; class ChainServiceCest { /** * @throws Exception */ public function testProcess(FunctionalTester $I): void { $chainService = $I->grabService(ChainService::class); $expectedMessage = 'test simple processor'. ' -> final processor'. ' -> Finish'; $I->assertSame($expectedMessage, $chainService->process()); } }
- Добавляем класс
App\Tests\Mock\FinalProcessorProxy
<?php namespace App\Tests\Mock; use App\Service\Chain\Processor\FinalProcessor; use App\Service\Chain\Processor\FinalProcessorInterface; class FinalProcessorProxy implements FinalProcessorInterface { public function __construct( private readonly FinalProcessor $baseProcessor ) { } public function process(string $source): string { return 'test final processor'; } }
- В файле
config/services
исправляем секцию[email protected]
App\Tests\Mock\FinalProcessorProxy: decorates: App\Service\Chain\Processor\FinalProcessor arguments: - '@.inner'
- Исправляем тест
App\Tests\functional\ChainServiceCest
<?php namespace App\Tests\functional; use App\Service\Chain\ChainService; use App\Tests\FunctionalTester; use Exception; class ChainServiceCest { /** * @throws Exception */ public function testProcess(FunctionalTester $I): void { $chainService = $I->grabService(ChainService::class); $expectedMessage = 'test final processor'. ' -> Finish'; $I->assertSame($expectedMessage, $chainService->process()); } }
- Переходим в браузере по адресу
http://localhost:7777/task?name=some_task
, видим идентификатор созданной задачи и созданную задачу в БД - В двух смежных вкладках браузера одновременно переходим по адресу
http://localhost:7777/task?name=other_task
, видим, что в одном из случаев задача не создалась, т.к. в БД есть уникальный индекс по названию. - Исправляем класс
App\Service\TaskService
<?php namespace App\Service; use App\Entity\Task; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Throwable; class TaskService { public function __construct( private readonly ManagerRegistry $managerRegistry, private EntityManagerInterface $entityManager ) { } /** * @throws \Doctrine\DBAL\Exception */ public function addTask(?string $name): ?int { if ($name === null) { return null; } try { $this->entityManager->getConnection()->beginTransaction(); $task = new Task(); $task->setName($name); $this->entityManager->persist($task); $this->entityManager->flush(); sleep(3); $this->entityManager->getConnection()->commit(); return $task->getId(); } catch (UniqueConstraintViolationException) { $this->entityManager->getConnection()->rollBack(); $this->entityManager->close(); /** @var EntityManagerInterface $entityManager */ $entityManager = $this->managerRegistry->resetManager(); $this->entityManager = $entityManager; return $this->addTask($name.'_fixed'); } catch (Throwable) { $this->entityManager->getConnection()->rollBack(); } return null; } }
- В двух смежных вкладках браузера одновременно переходим по адресу
http://localhost:7777/task?name=new_task
, видим, что в одном из случаев задача не создалась, т.к. в БД есть уникальный индекс по названию.
- В файле
tests/functional.suite.yml
раскомментируем настройки модуляDoctrine2
- Пересобираем акторов тестов командой
vendor/bin/codecept build
- Добавляем тест
App\Tests\functional\TaskServiceCest
<?php namespace App\Tests\functional; use App\Entity\Task; use App\Service\TaskService; use App\Tests\FunctionalTester; use Exception; class TaskServiceCest { private const TEST_TASK_NAME = 'Test task'; private const FIXED_TEST_TASK_NAME = 'Test task_fixed'; /** * @throws Exception */ public function testAddTask(FunctionalTester $I): void { $I->haveInRepository(Task::class, ['name' => self::TEST_TASK_NAME]); /** @var TaskService $taskService */ $taskService = $I->grabService(TaskService::class); $taskService->addTask(self::TEST_TASK_NAME); $I->canSeeInRepository(Task::class, ['name' => self::FIXED_TEST_TASK_NAME]); } }
- Выполняем миграции для тестового окружения командами
php bin/console doctrine:database:create --env=test php bin/console doctrine:migrations:migrate --env=test
- Запускаем тест командой
vendor/bin/codecept run tests/functional/TaskServiceCest.php
и видим ошибкуEntityManager is closed
- В файле
tests/functional.suite.yml
в секцииmodules.enabled.Doctrine2
исправляем значение параметраcleanup
наfalse
- Запускаем тест командой
vendor/bin/codecept run tests/functional/TaskServiceCest.php
, он успешно проходит - Запускаем тест повторно, и видим ошибку, возникающую ещё до вызова тестируемого функционала.
- Удаляем тестовые данные из БД
- Исправляем тест
App\Tests\functional\TaskServiceCest
<?php namespace App\Tests\functional; use App\Entity\Task; use App\Service\TaskService; use App\Tests\FunctionalTester; use Doctrine\ORM\EntityManagerInterface; use Exception; class TaskServiceCest { private const TEST_TASK_NAME = 'Test task'; private const FIXED_TEST_TASK_NAME = 'Test task_fixed'; /** * @throws Exception */ public function testAddTask(FunctionalTester $I): void { $I->haveInRepository(Task::class, ['name' => self::TEST_TASK_NAME]); /** @var TaskService $taskService */ $taskService = $I->grabService(TaskService::class); $taskService->addTask(self::TEST_TASK_NAME); $I->canSeeInRepository(Task::class, ['name' => self::FIXED_TEST_TASK_NAME]); // cleanup $task1 = $I->grabEntityFromRepository(Task::class, ['name' => self::TEST_TASK_NAME]); $task2 = $I->grabEntityFromRepository(Task::class, ['name' => self::FIXED_TEST_TASK_NAME]); /** @var EntityManagerInterface $entityManager */ $entityManager = $I->grabService('doctrine.orm.entity_manager'); $entityManager->remove($task1); $entityManager->remove($task2); $I->flushToDatabase(); } }
- Запускаем тест дважды командой
vendor/bin/codecept run tests/functional/TaskServiceCest.php
, он успешно проходит оба раза
- Исправляем класс
App\Service\TaskService
<?php namespace App\Service; use App\Entity\Task; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityRepository; use Doctrine\Persistence\ManagerRegistry; use Throwable; class TaskService { public function __construct( private readonly ManagerRegistry $managerRegistry, private EntityManagerInterface $entityManager ) { } /** * @throws \Doctrine\DBAL\Exception */ public function addTask(?string $name): ?int { if ($name === null) { return null; } try { $this->entityManager->getConnection()->beginTransaction(); /** @var EntityRepository $taskRepository */ $taskRepository = $this->entityManager->getRepository(Task::class); $existingTask = $taskRepository->findOneBy(['name' => $name]); if ($existingTask !== null) { return null; } $task = new Task(); $task->setName($name); $this->entityManager->persist($task); $this->entityManager->flush(); sleep(3); $this->entityManager->getConnection()->commit(); return $task->getId(); } catch (UniqueConstraintViolationException) { $this->entityManager->getConnection()->rollBack(); $this->entityManager->close(); /** @var EntityManagerInterface $entityManager */ $entityManager = $this->managerRegistry->resetManager(); $this->entityManager = $entityManager; return $this->addTask($name.'_fixed'); } catch (Throwable) { $this->entityManager->getConnection()->rollBack(); } return null; } }
- Запускаем тест командой
vendor/bin/codecept run tests/functional/TaskServiceCest.php
, он выдаёт ошибку - Добавляем класс
App\Repository\TaskRepository
<?php namespace App\Repository; use App\Entity\Task; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Persistence\ManagerRegistry; class TaskRepository extends ServiceEntityRepository { public function __construct(ManagerRegistry $registry) { parent::__construct($registry, Task::class); } public function findByName(string $name): ?Task { /** @var Task|null $task */ $task = $this->findOneBy(['name' => $name]); return $task; } }
- В классе
App\Entity\Task
исправляем атрибут класса#[ORM\Entity(repositoryClass: TaskRepository::class)]
- Исправляем класс
App\Service\TaskService
<?php namespace App\Service; use App\Entity\Task; use App\Repository\TaskRepository; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Throwable; class TaskService { public function __construct( private readonly ManagerRegistry $managerRegistry, private EntityManagerInterface $entityManager, ) { } /** * @throws \Doctrine\DBAL\Exception */ public function addTask(?string $name): ?int { if ($name === null) { return null; } try { $this->entityManager->getConnection()->beginTransaction(); /** @var TaskRepository $taskRepository */ $taskRepository = $this->entityManager->getRepository(Task::class); $existingTask = $taskRepository->findByName($name); if ($existingTask !== null) { return null; } $task = new Task(); $task->setName($name); $this->entityManager->persist($task); $this->entityManager->flush(); sleep(3); $this->entityManager->getConnection()->commit(); return $task->getId(); } catch (UniqueConstraintViolationException) { $this->entityManager->getConnection()->rollBack(); $this->entityManager->close(); /** @var EntityManagerInterface $entityManager */ $entityManager = $this->managerRegistry->resetManager(); $this->entityManager = $entityManager; return $this->addTask($name.'_fixed'); } catch (Throwable) { $this->entityManager->getConnection()->rollBack(); } return null; } }
- Добавляем класс
App\Tests\Mock\TaskRepositoryMock
<?php namespace App\Tests\Mock; use App\Entity\Task; use App\Repository\TaskRepository; class TaskRepositoryMock extends TaskRepository { private ?string $emulateRaceConditionForName = null; public function enableEmulateRaceConditionForName(string $name): void { $this->emulateRaceConditionForName = $name; } public function findByName(string $name): ?Task { if ($this->emulateRaceConditionForName === $name) { $task = new Task(); $task->setName($name); $this->getEntityManager()->persist($task); $this->getEntityManager()->flush(); return null; } return parent::findByName($name); } }
- В файле
config/services
добавляем в секцию[email protected]
новый сервисApp\Repository\TaskRepository: public: true autowire: true autoconfigure: true class: App\Tests\Mock\TaskRepositoryMock
- Исправляем тест
App\Tests\functional\TaskServiceCest
<?php namespace App\Tests\functional; use App\Entity\Task; use App\Service\TaskService; use App\Tests\FunctionalTester; use App\Tests\Mock\TaskRepositoryMock; use Doctrine\ORM\EntityManagerInterface; use Exception; class TaskServiceCest { private const TEST_TASK_NAME = 'Test task'; private const FIXED_TEST_TASK_NAME = 'Test task_fixed'; /** * @throws Exception */ public function testAddTask(FunctionalTester $I): void { /** @var EntityManagerInterface $entityManager */ $entityManager = $I->grabService('doctrine.orm.entity_manager'); /** @var TaskRepositoryMock $taskRepository */ $taskRepository = $entityManager->getRepository(Task::class); $taskRepository->enableEmulateRaceConditionForName(self::TEST_TASK_NAME); /** @var TaskService $taskService */ $taskService = $I->grabService(TaskService::class); $taskService->addTask(self::TEST_TASK_NAME); $I->canSeeInRepository(Task::class, ['name' => self::FIXED_TEST_TASK_NAME]); // cleanup $task2 = $I->grabEntityFromRepository(Task::class, ['name' => self::FIXED_TEST_TASK_NAME]); $entityManager->remove($task2); $I->flushToDatabase(); } }
- Запускаем тест командой
vendor/bin/codecept run tests/functional/TaskServiceCest.php
, он успешно проходит
- Добавляем класс
App\Tests\Helper\MockService
<?php namespace App\Tests\Helper; use Codeception\Module; class MockService extends Module { /** * @throws \Codeception\Exception\ModuleException */ public function wantMockService(string $id, ?object $mock): void { /** @var Module\Symfony $symfony */ $symfony = $this->getModule('Symfony'); $symfony->_getContainer() ->set($id, $mock); } }
- В файле
config/services.yaml
в секции[email protected]
убираем описание сервисаApp\Tests\Mock\FinalProcessorProxy
- В файле
tests/functional.suite.yml
в секциюmodules.enabled
модуль\App\Tests\Helper\MockService
- Исполняем команду
vendor/bin/codecept build
- Исправляем тест
App\Tests\functional\ChainServiceCest
<?php namespace App\Tests\functional; use App\Service\Chain\ChainService; use App\Service\Chain\Processor\SimplePlainProcessor; use App\Tests\FunctionalTester; use Codeception\Stub; use Exception; class ChainServiceCest { /** * @throws Exception */ public function testProcess(FunctionalTester $I): void { $simplePlainProcessorMock = Stub::make(SimplePlainProcessor::class, ['process' => 'test simple plain processor']); $I->wantMockService(SimplePlainProcessor::class, $simplePlainProcessorMock); $chainService = $I->grabService(ChainService::class); $expectedMessage = 'test simple plain processor'. ' -> final plain processor'. ' -> simple processor'. ' -> final processor'. ' -> Finish'; $I->assertSame($expectedMessage, $chainService->process()); } }
- Запускаем тест командой
vendor/bin/codecept run tests/functional/ChainServiceCest.php
, он успешно проходит
- Добавляем класс
App\Tests\Helper\CleanupService
<?php namespace App\Tests\Helper; use Codeception\Module; class CleanupService extends Module { /** * @throws \Codeception\Exception\ModuleException */ public function wantCleanupEntities(array $entities): void { /** @var Module\Doctrine2 $doctrine2 */ $doctrine2 = $this->getModule('Doctrine2'); $entityManager = $doctrine2->_getEntityManager(); foreach ($entities as $entity) { $entityManager->remove($entity); } $doctrine2->flushToDatabase(); } }
- В файле
tests/functional.suite.yml
в секциюmodules.enabled
модуль\App\Tests\Helper\CleanupService
- Исполняем команду
vendor/bin/codecept build
- Исправляем тест
App\Tests\functional\TaskServiceCest
<?php namespace App\Tests\functional; use App\Entity\Task; use App\Service\TaskService; use App\Tests\FunctionalTester; use App\Tests\Mock\TaskRepositoryMock; use Doctrine\ORM\EntityManagerInterface; use Exception; class TaskServiceCest { private const TEST_TASK_NAME = 'Test task'; private const FIXED_TEST_TASK_NAME = 'Test task_fixed'; /** * @throws Exception */ public function testAddTask(FunctionalTester $I): void { /** @var EntityManagerInterface $entityManager */ $entityManager = $I->grabService('doctrine.orm.entity_manager'); /** @var TaskRepositoryMock $taskRepository */ $taskRepository = $entityManager->getRepository(Task::class); $taskRepository->enableEmulateRaceConditionForName(self::TEST_TASK_NAME); /** @var TaskService $taskService */ $taskService = $I->grabService(TaskService::class); $taskService->addTask(self::TEST_TASK_NAME); $I->canSeeInRepository(Task::class, ['name' => self::FIXED_TEST_TASK_NAME]); $I->wantCleanupEntities( [$I->grabEntityFromRepository(Task::class, ['name' => self::FIXED_TEST_TASK_NAME])] ); } }
- Запускаем дважды тест командой
vendor/bin/codecept run tests/functional/TaskServiceCest.php
, он успешно проходит оба раза