PHPUnit. Мокаем Doctrine Entity Manager

15 ИЮНЯ 2022

------------------------------------------------------------------------

Наш телеграм-канал: t.me/@mailsgun_online

Youtube: youtube.com/@mailsgun_online

------------------------------------------------------------------------

Во многих современных приложениях для работы с базой данных используется проект Doctrine ORM.

Хорошим тоном считается выносить работу с БД в сервисы. А сервисы нужно тестировать.

Для тестирования сервисов можно подключить тестовую базу данных, а можно замокать Менеджер сущностей и репозитории. С первым вариантом всё понятно, но не всегда есть смысл разворачивать БД для тестирования сервиса. Об этом и поговорим.

Для примера, возьмем такой сервис:

src/Service/User/UserService.php

<?php
// src/Service/User/UserService.php
namespace AppServiceUser;
use AppEntityCode;
use AppEntityUser;
use AppRepositoryCodeRepository;
use AppRepositoryUserRepository;
use AppServiceGeneratorCodeGenerator;
use AppServiceSenderSenderService;
use AppServiceUserExceptionLoginAlreadyExistsException;
use AppServiceUserExceptionReferrerUserNotFoundException;
use DoctrineORMEntityManagerInterface;
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;
    }
}

Нам нужно протестировать его единственный метод create().
Выделим следующие кейсы:

  • Успешное создание пользователя без реферрера
  • Успешное создание пользователя с реферрером
  • Ошибка «Логин уже занят»
  • Ошибка «Реферрер не найден»

Для теста сервиса нам понадобится объект, реализующий интерфейс DoctrineORMEntityManagerInterface

Вариант 1. Используем реальную базу данных

Напишем базовый класс для тестов, от которого в последующем будем наследоваться.

tests/TestCase.php

<?php
// tests/TestCase.php
namespace Tests;
use DoctrineCommonAnnotationsAnnotationReader;
use DoctrineCommonCacheArrayCache;
use DoctrineORMEntityManager;
use DoctrineORMEntityManagerInterface;
use DoctrineORMMappingDriverAnnotationDriver;
use DoctrineORMToolsSchemaTool;
use DoctrineORMToolsSetup;
use PHPUnitFrameworkTestCase 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;
    }
}

Теперь есть смысл для тестов задать переменные окружения. Внесём их в файл phpunit.xml в секции php. Я буду использовать БД sqlite

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>

Теперь напишем тест сервиса

tests/Unit/Service/UserServiceTest.php

<?php
// tests/Unit/Service/UserServiceTest.php
namespace TestsUnitService;
use AppEntityCode;
use AppEntityUser;
use AppRepositoryCodeRepository;
use AppRepositoryUserRepository;
use AppServiceGeneratorCodeGenerator;
use AppServiceSenderSenderService;
use AppServiceUserExceptionLoginAlreadyExistsException;
use AppServiceUserExceptionReferrerUserNotFoundException;
use AppServiceUserUserService;
use TestsTestCase;
use DoctrineORMEntityManagerInterface;
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);
    }
}

Убедимся, что наш сервис работает исправно

./vendor/bin/phpunit

Вариант 2. Используем MockBuilder

Каждый раз строить базу данных — это тяжело. Тем более phpunit даёт нам возможность на лету собирать моки с помощью mockBuilder. Пример можно подсмотреть в документации Symfony

Пример из документации Symfony

// tests/Salary/SalaryCalculatorTest.php
namespace AppTestsSalary;
use AppEntityEmployee;
use AppSalarySalaryCalculator;
use DoctrineCommonPersistenceObjectManager;
use DoctrineCommonPersistenceObjectRepository;
use PHPUnitFrameworkTestCase;
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));
    }
}

Вариант рабочий, но есть проблемы. Нужно четко знать, в какой последовательности код обращается к методам EntityManager.
Например, если разработчик поменяет местами проверку существования реферрера и проверку на занятость логина, тест сломается. А приложение — нет.

Я предлагаю вариант умного мокинга EntityManager, который хранит все свои данные в памяти и не использует реальную базу данных.

Вариант 3. Используем MockBuilder с хранением данных в памяти.

Для гибкости, добавим переменную окружения, чтобы можно было воспользоваться и реальной базой данных. Внесем зименения в phpunit.xml

изменения phpunit.xml

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

Теперь модифицируем базовый класс

модифицированный tests/TestCase.php

<?php
// tests/TestCase.php
namespace Tests;
use AppEntityCode;
use AppEntityUser;
use AppRepositoryCodeRepository;
use AppRepositoryUserRepository;
use Closure;
use DoctrineCommonAnnotationsAnnotationReader;
use DoctrineCommonCacheArrayCache;
use DoctrineORMEntityManager;
use DoctrineORMEntityManagerInterface;
use DoctrineORMMappingDriverAnnotationDriver;
use DoctrineORMToolsSchemaTool;
use DoctrineORMToolsSetup;
use PHPUnitFrameworkMockObjectMockObject;
use PHPUnitFrameworkTestCase 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);
        };
    }
}

Теперь мы снова можем запустить тест и убедиться, что наш сервис работает, без подключения к базе данных.

./vendor/bin/phpunit

Исходный код доступен на Github

Платежная система

Поделиться публикацией

Source: habr1

Ну и напоследок напоминаю вам о нашем новом проекте vkserfing bot.