PHPUnit. Gerenciador de entidades de doutrina chorosa

Muitos aplicativos modernos de banco de dados usam o projeto Doctrine ORM .


É uma boa prática levar o trabalho com o banco de dados aos serviços. E os serviços precisam ser testados.


Para testar serviços, você pode conectar um banco de dados de teste ou bloquear o Entity Manager e os repositórios. Com a primeira opção, tudo fica claro, mas nem sempre faz sentido implantar um banco de dados para testar o serviço. Nós vamos falar sobre isso.


Por exemplo, tome o seguinte serviço:


src / Serviço / Usuário / UserService.php
<?php // src/Service/User/UserService.php namespace App\Service\User; use App\Entity\Code; use App\Entity\User; use App\Repository\CodeRepository; use App\Repository\UserRepository; use App\Service\Generator\CodeGenerator; use App\Service\Sender\SenderService; use App\Service\User\Exception\LoginAlreadyExistsException; use App\Service\User\Exception\ReferrerUserNotFoundException; use Doctrine\ORM\EntityManagerInterface; class UserService { /** @var EntityManagerInterface */ private $em; /** @var UserRepository */ private $users; /** @var CodeRepository */ private $codes; /** @var SenderService */ private $sender; /** @var CodeGenerator */ private $generator; public function __construct(EntityManagerInterface $em, SenderService $sender, CodeGenerator $generator) { $this->em = $em; $this->users = $em->getRepository(User::class); $this->codes = $em->getRepository(Code::class); $this->sender = $sender; $this->generator = $generator; } /** * @param string $login * @param string $email * @param string|null $referrerLogin * @return User * @throws LoginAlreadyExistsException * @throws ReferrerUserNotFoundException */ public function create(string $login, string $email, ?string $referrerLogin = null): User { $exists = $this->users->findOneByLogin($login); if ($exists) throw new LoginAlreadyExistsException(); $referrer = null; if ($referrerLogin) { $referrer = $this->users->findOneByLogin($referrerLogin); if (!$referrer) throw new ReferrerUserNotFoundException(); } $user = (new User())->setLogin($login)->setEmail($email)->setReferrer($referrer); $code = (new Code())->setEmail($email)->setCode($this->generator->generate()); $this->sender->sendCode($code); $this->em->persist($user); $this->em->persist($code); $this->em->flush(); return $user; } } 

Precisamos testar seu único método create() .
Selecione os seguintes casos:


  • Criação de usuário bem-sucedida sem referenciador
  • Criação de usuário bem-sucedida com referenciador
  • Erro "Login já realizado"
  • Erro "Referenciador não encontrado"

Para testar o serviço, precisamos de um objeto que implemente a interface Doctrine\ORM\EntityManagerInterface


Opção 1. Usamos um banco de dados real


Escreveremos uma classe base para testes, da qual herdaremos mais tarde.


tests / TestCase.php
 <?php // tests/TestCase.php namespace Tests; use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Cache\ArrayCache; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\Driver\AnnotationDriver; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\Setup; use PHPUnit\Framework\TestCase as BaseTestCase; abstract class TestCase extends BaseTestCase { protected function getEntityManager(): EntityManagerInterface { $paths = [ dirname(__DIR__) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Entity', ]; $cache = new ArrayCache(); $driver = new AnnotationDriver(new AnnotationReader(), $paths); $config = Setup::createAnnotationMetadataConfiguration($paths, false); $config->setMetadataCacheImpl($cache); $config->setQueryCacheImpl($cache); $config->setMetadataDriverImpl($driver); $connection = array( 'driver' => getenv('DB_DRIVER'), 'path' => getenv('DB_PATH'), 'user' => getenv('DB_USER'), 'password' => getenv('DB_PASSWORD'), 'dbname' => getenv('DB_NAME'), ); $em = EntityManager::create($connection, $config); /* *       . *          */ $schema = new SchemaTool($em); $schema->dropSchema($em->getMetadataFactory()->getAllMetadata()); $schema->createSchema($em->getMetadataFactory()->getAllMetadata()); return $em; } } 

Agora faz sentido para os testes definir variáveis ​​de ambiente. Adicione-os ao arquivo phpunit.xml na seção php . Vou usar o sqlite db


phpunit.xml
 <?xml version="1.0" encoding="UTF-8"?> <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.1/phpunit.xsd" bootstrap="vendor/autoload.php" executionOrder="depends,defects" forceCoversAnnotation="true" beStrictAboutCoversAnnotation="true" beStrictAboutOutputDuringTests="true" beStrictAboutTodoAnnotatedTests="true" verbose="true" colors="true"> <php> <env name="DB_DRIVER" value="pdo_sqlite" /> <env name="DB_PATH" value="var/db-test.sqlite" /> <env name="DB_USER" value="" /> <env name="DB_PASSWORD" value="" /> <env name="DB_NAME" value="" /> </php> <testsuites> <testsuite name="default"> <directory>tests/Unit</directory> </testsuite> </testsuites> <filter> <whitelist processUncoveredFilesFromWhitelist="true"> <directory suffix=".php">src</directory> </whitelist> </filter> </phpunit> 

Agora vamos escrever um teste de serviço


testes / Unidade / Serviço / UserServiceTest.php
 <?php // tests/Unit/Service/UserServiceTest.php namespace Tests\Unit\Service; use App\Entity\Code; use App\Entity\User; use App\Repository\CodeRepository; use App\Repository\UserRepository; use App\Service\Generator\CodeGenerator; use App\Service\Sender\SenderService; use App\Service\User\Exception\LoginAlreadyExistsException; use App\Service\User\Exception\ReferrerUserNotFoundException; use App\Service\User\UserService; use Tests\TestCase; use Doctrine\ORM\EntityManagerInterface; class UserServiceTest extends TestCase { /** * @var UserService */ protected $service; /** * @var EntityManagerInterface */ protected $em; public function setUp(): void { parent::setUp(); $this->em = $this->getEntityManager(); $this->service = new UserService($this->em, new SenderService(), new CodeGenerator()); } /** * @throws LoginAlreadyExistsException * @throws ReferrerUserNotFoundException */ public function testCreateSuccessWithoutReferrer() { //        $login = 'case1'; $email = $login . '@localhost'; $user = $this->service->create($login, $email); // ,       $this->assertInstanceOf(User::class, $user); $this->assertSame($login, $user->getLogin()); $this->assertSame($email, $user->getEmail()); $this->assertFalse($user->isApproved()); // ,      /** @var UserRepository $userRepo */ $userRepo = $this->em->getRepository(User::class); $u = $userRepo->findOneByLogin($login); $this->assertInstanceOf(User::class, $u); $this->assertSame($login, $u->getLogin()); $this->assertSame($email, $u->getEmail()); $this->assertFalse($u->isApproved()); // ,       /** @var CodeRepository $codeRepo */ $codeRepo = $this->em->getRepository(Code::class); $c = $codeRepo->findLastByEmail($email); $this->assertInstanceOf(Code::class, $c); } /** * @throws LoginAlreadyExistsException * @throws ReferrerUserNotFoundException */ public function testCreateSuccessWithReferrer() { //      $referrerLogin = 'referer'; $referrer = new User(); $referrer ->setLogin($referrerLogin) ->setEmail($referrerLogin.'@localhost') ; $this->em->persist($referrer); $this->em->flush(); //        $login = 'case2'; $email = $login . '@localhost'; $user = $this->service->create($login, $email, $referrerLogin); // ,       $this->assertInstanceOf(User::class, $user); $this->assertSame($login, $user->getLogin()); $this->assertSame($email, $user->getEmail()); $this->assertFalse($user->isApproved()); $this->assertSame($referrer, $user->getReferrer()); // ,      /** @var UserRepository $userRepo */ $userRepo = $this->em->getRepository(User::class); $u = $userRepo->findOneByLogin($login); $this->assertInstanceOf(User::class, $u); $this->assertSame($login, $u->getLogin()); $this->assertSame($email, $u->getEmail()); $this->assertFalse($u->isApproved()); // ,       /** @var CodeRepository $codeRepo */ $codeRepo = $this->em->getRepository(Code::class); $c = $codeRepo->findLastByEmail($email); $this->assertInstanceOf(Code::class, $c); } /** * @throws LoginAlreadyExistsException * @throws ReferrerUserNotFoundException */ public function testCreateFailWithNonexistentReferrer() { //   ,     ReferrerUserNotFoundException $this->expectException(ReferrerUserNotFoundException::class); $referrerLogin = 'nonexistent-referer'; $login = 'case3'; $email = $login . '@localhost'; //       $this->service->create($login, $email, $referrerLogin); } /** * @throws LoginAlreadyExistsException * @throws ReferrerUserNotFoundException */ public function testCreateFailWithExistentLogin() { //   ,     LoginAlreadyExistsException $this->expectException(LoginAlreadyExistsException::class); //       $login = 'case4'; $email = $login . '@localhost'; //       ,    $existentUser = new User(); $existentUser ->setLogin($login) ->setEmail($login.'@localhost') ; $this->em->persist($existentUser); $this->em->flush(); //       $this->service->create($login, $email, null); } } 

Verifique se o nosso serviço está funcionando corretamente


 ./vendor/bin/phpunit 

Opção 2. Usando o MockBuilder


Construir um banco de dados sempre é difícil. Especialmente o phpunit nos dá a oportunidade de coletar moki em tempo real usando o mockBuilder. Um exemplo pode ser encontrado na documentação do Symfony.


Exemplo de documentação do Symfony
 // tests/Salary/SalaryCalculatorTest.php namespace App\Tests\Salary; use App\Entity\Employee; use App\Salary\SalaryCalculator; use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Common\Persistence\ObjectRepository; use PHPUnit\Framework\TestCase; class SalaryCalculatorTest extends TestCase { public function testCalculateTotalSalary() { $employee = new Employee(); $employee->setSalary(1000); $employee->setBonus(1100); // Now, mock the repository so it returns the mock of the employee $employeeRepository = $this->createMock(ObjectRepository::class); // use getMock() on PHPUnit 5.3 or below // $employeeRepository = $this->getMock(ObjectRepository::class); $employeeRepository->expects($this->any()) ->method('find') ->willReturn($employee); // Last, mock the EntityManager to return the mock of the repository $objectManager = $this->createMock(ObjectManager::class); // use getMock() on PHPUnit 5.3 or below // $objectManager = $this->getMock(ObjectManager::class); $objectManager->expects($this->any()) ->method('getRepository') ->willReturn($employeeRepository); $salaryCalculator = new SalaryCalculator($objectManager); $this->assertEquals(2100, $salaryCalculator->calculateTotalSalary(1)); } } 

A opção está funcionando, mas há problemas. Você precisa saber claramente em qual sequência o código acessa os métodos EntityManager.
Por exemplo, se um desenvolvedor trocar a verificação da existência de um referenciador e a verificação de logon ocupado, o teste será interrompido. Mas a aplicação não é.


Proponho a opção do EntityManager de moking inteligente, que armazena todos os dados na memória e não usa um banco de dados real.


Opção 3. Usamos o MockBuilder com armazenamento de dados na memória.


Para flexibilidade, adicionaremos uma variável de ambiente para que você possa usar um banco de dados real. Vamos phpunit.xml inverno no phpunit.xml


Alterações no phpunit.xml
 <?xml version="1.0" encoding="UTF-8"?> <!-- ... --> <php> <!-- ... --> <env name="EMULATE_BD" value="1" /> <!-- ... --> </php> <!-- ... --> 

Agora modifique a classe base


testes modificados / TestCase.php
 <?php // tests/TestCase.php namespace Tests; use App\Entity\Code; use App\Entity\User; use App\Repository\CodeRepository; use App\Repository\UserRepository; use Closure; use Doctrine\Common\Annotations\AnnotationReader; use Doctrine\Common\Cache\ArrayCache; use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\Driver\AnnotationDriver; use Doctrine\ORM\Tools\SchemaTool; use Doctrine\ORM\Tools\Setup; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase as BaseTestCase; use ReflectionClass; abstract class TestCase extends BaseTestCase { /** * @var MockObject[] */ private $_mock = []; private $_data = [ User::class => [], Code::class => [], ]; private $_persist = [ User::class => [], Code::class => [], ]; /** * @var Closure[][] */ private $_fn = []; public function __construct($name = null, array $data = [], $dataName = '') { parent::__construct($name, $data, $dataName); $this->initFn(); } protected function getEntityManager(): EntityManagerInterface { $emulate = (int)getenv('EMULATE_BD'); return $emulate ? $this->getMockEntityManager() : $this->getRealEntityManager(); } protected function getRealEntityManager(): EntityManagerInterface { $paths = [ dirname(__DIR__) . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'Entity', ]; $cache = new ArrayCache(); $driver = new AnnotationDriver(new AnnotationReader(), $paths); $config = Setup::createAnnotationMetadataConfiguration($paths, false); $config->setMetadataCacheImpl($cache); $config->setQueryCacheImpl($cache); $config->setMetadataDriverImpl($driver); $connection = array( 'driver' => getenv('DB_DRIVER'), 'path' => getenv('DB_PATH'), 'user' => getenv('DB_USER'), 'password' => getenv('DB_PASSWORD'), 'dbname' => getenv('DB_NAME'), ); $em = EntityManager::create($connection, $config); /* *       . *          */ $schema = new SchemaTool($em); $schema->dropSchema($em->getMetadataFactory()->getAllMetadata()); $schema->createSchema($em->getMetadataFactory()->getAllMetadata()); return $em; } protected function getMockEntityManager(): EntityManagerInterface { return $this->mock(EntityManagerInterface::class); } protected function mock($class) { if (!array_key_exists($class, $this->_mock)) { /* *     */ $mock = $this->getMockBuilder($class) ->disableOriginalConstructor() ->getMock() ; /* *     */ foreach ($this->_fn[$class] as $method => $fn) { $mock /*    */ ->expects($this->any()) /*  $method */ ->method($method) /*  (  )  */ ->with() /*     */ ->will($this->returnCallback($fn)) ; } $this->_mock[$class] = $mock; } return $this->_mock[$class]; } /* *    . *     $fn_[][] */ private function initFn() { /* * EntityManagerInterface::persist($object) -      */ $this->_fn[EntityManagerInterface::class]['persist'] = function ($object) { $entity = get_class($object); switch ($entity) { case User::class: /** @var User $object */ if (!$object->getId()) { $id = count($this->_persist[$entity]) + 1; $reflection = new ReflectionClass($object); $property = $reflection->getProperty('id'); $property->setAccessible(true); $property->setValue($object, $id); } $id = $object->getId(); break; case Code::class: /** @var Code $object */ if (!$object->getId()) { $id = count($this->_persist[$entity]) + 1; $reflection = new ReflectionClass($object); $property = $reflection->getProperty('id'); $property->setAccessible(true); $property->setValue($object, $id); } $id = $object->getId(); break; default: $id = spl_object_hash($object); } $this->_persist[$entity][$id] = $object; }; /* * EntityManagerInterface::flush() -      */ $this->_fn[EntityManagerInterface::class]['flush'] = function () { $this->_data = array_replace_recursive($this->_data, $this->_persist); }; /* * EntityManagerInterface::getRepository($className) -    */ $this->_fn[EntityManagerInterface::class]['getRepository'] = function ($className) { switch ($className) { case User::class: return $this->mock(UserRepository::class); break; case Code::class: return $this->mock(CodeRepository::class); break; } return null; }; /* * UserRepository::findOneByLogin($login) -       */ $this->_fn[UserRepository::class]['findOneByLogin'] = function ($login) { foreach ($this->_data[User::class] as $user) { /** @var User $user */ if ($user->getLogin() == $login) return $user; } return null; }; /* * CodeRepository::findOneByCodeAndEmail -      *        */ $this->_fn[CodeRepository::class]['findOneByCodeAndEmail'] = function ($code, $email) { $result = []; foreach ($this->_data[Code::class] as $c) { /** @var Code $c */ if ($c->getEmail() == $email && $c->getCode() == $code) { $result[$c->getId()] = $c; } } if (!$result) return null; return array_shift($result); }; /* * CodeRepository::findLastByEmail($email) -  ()    *     */ $this->_fn[CodeRepository::class]['findLastByEmail'] = function ($email) { $result = []; foreach ($this->_data[Code::class] as $c) { /** @var Code $c */ if ($c->getEmail() == $email) { $result[$c->getId()] = $c; } } if (!$result) return null; return array_shift($result); }; } } 

Agora podemos executar o teste novamente e garantir que nosso serviço funcione sem conectar-se ao banco de dados.


 ./vendor/bin/phpunit 

Código-fonte disponível no Github

Source: https://habr.com/ru/post/pt452244/


All Articles