PHPUnit. Gerente de entidad de Doctrina llorona

Muchas aplicaciones de bases de datos modernas utilizan el proyecto Doctrine ORM .


Se considera una buena pr谩ctica llevar el trabajo con la base de datos a los servicios. Y los servicios deben ser probados.


Para probar los servicios, puede conectar una base de datos de prueba o puede bloquear el Entity Manager y los repositorios. Con la primera opci贸n, todo est谩 claro, pero no siempre tiene sentido implementar una base de datos para probar el servicio. Hablaremos de esto.


Por ejemplo, tome el siguiente servicio:


src / Service / User / 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; } } 

Necesitamos probar su 煤nico m茅todo create() .
Seleccione los siguientes casos:


  • Creaci贸n de usuario exitosa sin referencia
  • Creaci贸n de usuario exitosa con referencia
  • Error "Inicio de sesi贸n ya tomado"
  • Error "Referencia no encontrada"

Para probar el servicio, necesitamos un objeto que implemente la interfaz Doctrine\ORM\EntityManagerInterface


Opci贸n 1. Usamos una base de datos real


Escribiremos una clase base para las pruebas, de la que luego heredaremos.


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

Ahora tiene sentido que las pruebas establezcan variables de entorno. phpunit.xml archivo phpunit.xml en la secci贸n php . Voy a usar 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> 

Ahora escribiremos una prueba de servicio


tests / Unit / Service / 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); } } 

Aseg煤rese de que nuestro servicio funcione correctamente


 ./vendor/bin/phpunit 

Opci贸n 2. Usando MockBuilder


Construir una base de datos cada vez es dif铆cil. Especialmente phpunit nos da la oportunidad de recolectar moki sobre la marcha usando mockBuilder. Se puede encontrar un ejemplo en la documentaci贸n de Symfony.


Ejemplo de documentaci贸n de 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)); } } 

La opci贸n est谩 funcionando, pero hay problemas. Debe saber claramente en qu茅 secuencia el c贸digo accede a los m茅todos de EntityManager.
Por ejemplo, si un desarrollador intercambia la verificaci贸n de la existencia de un referente y una verificaci贸n de inicio de sesi贸n ocupado, la prueba se interrumpir谩. Pero la aplicaci贸n no lo es.


Propongo la opci贸n de Moking inteligente EntityManager, que almacena todos sus datos en la memoria y no utiliza una base de datos real.


Opci贸n 3. Utilizamos MockBuilder con almacenamiento de datos en la memoria.


Para mayor flexibilidad, agregaremos una variable de entorno para que pueda usar una base de datos real. Hagamos la invernada en phpunit.xml


phpunit.xml cambios
 <?xml version="1.0" encoding="UTF-8"?> <!-- ... --> <php> <!-- ... --> <env name="EMULATE_BD" value="1" /> <!-- ... --> </php> <!-- ... --> 

Ahora modifique la clase base


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

Ahora podemos ejecutar la prueba nuevamente y asegurarnos de que nuestro servicio funcione sin conectarse a la base de datos.


 ./vendor/bin/phpunit 

C贸digo fuente disponible en Github

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


All Articles