PHPUnit. Gestionnaire d'entité de la doctrine des pleurs

De nombreuses applications de bases de données modernes utilisent le projet Doctrine ORM .


Il est considéré comme une bonne pratique de confier le travail avec la base de données aux services. Et les services doivent être testés.


Pour tester les services, vous pouvez connecter une base de données de test ou verrouiller Entity Manager et les référentiels. Avec la première option, tout est clair, mais cela n'a pas toujours de sens de déployer une base de données pour tester le service. Nous en parlerons.


Par exemple, prenez le service suivant:


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

Nous devons tester sa seule méthode create() .
Sélectionnez les cas suivants:


  • Création d'utilisateurs réussie sans référent
  • Création d'utilisateurs réussie avec référent
  • Erreur "Connexion déjà prise"
  • Erreur "Référent introuvable"

Pour tester le service, nous avons besoin d'un objet qui implémente l'interface Doctrine\ORM\EntityManagerInterface


Option 1. Nous utilisons une vraie base de données


Nous allons écrire une classe de base pour les tests, dont nous hériterons plus tard.


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

Maintenant, il est logique que les tests définissent des variables d'environnement. Ajoutez-les au fichier phpunit.xml dans la section php . Je vais utiliser 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> 

Nous allons maintenant écrire un test de service


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

Assurez-vous que notre service fonctionne correctement


 ./vendor/bin/phpunit 

Option 2. Utilisation de MockBuilder


Construire une base de données à chaque fois est difficile. En particulier, phpunit nous donne la possibilité de collecter des moki à la volée en utilisant mockBuilder. Un exemple peut être trouvé dans la documentation Symfony.


Exemple de documentation 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)); } } 

L'option fonctionne, mais il y a des problèmes. Vous devez savoir clairement dans quel ordre le code accède aux méthodes EntityManager.
Par exemple, si un développeur échange la vérification de l'existence d'un référent et la vérification d'une connexion occupée, le test échoue. Mais l'application ne l'est pas.


Je propose l'option de moking intelligent EntityManager, qui stocke toutes ses données en mémoire et n'utilise pas une vraie base de données.


Option 3. Nous utilisons MockBuilder avec le stockage de données en mémoire.


Pour plus de flexibilité, nous allons ajouter une variable d'environnement afin que vous puissiez utiliser une vraie base de données. Faisons hivernage dans phpunit.xml


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

Modifiez maintenant la classe de base


tests modifiés / 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); }; } } 

Nous pouvons maintenant relancer le test et nous assurer que notre service fonctionne sans se connecter à la base de données.


 ./vendor/bin/phpunit 

Code source disponible sur Github

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


All Articles