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


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


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


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


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

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


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

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


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


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


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

Теперь есть смысл для тестов задать переменные окружения. Внесём их в файл 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 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);
    }
}

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


./vendor/bin/phpunit

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


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


Пример из документации 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));
    }
}

Вариант рабочий, но есть проблемы. Нужно четко знать, в какой последовательности код обращается к методам 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 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);
        };
    }
}

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


./vendor/bin/phpunit

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

Комментарии (10)


  1. Maksclub
    20.05.2019 08:56
    +1

    Вариант 4


    Используем в сервисе интерфейс нужного для нашего домена репозитория
    Репозитории не создаём в конструкторе, а внедряем снаружи.


    Тогда все прекрасно тестируется: делается упрощённая реализация репы и работа идёт с ней в вашем сервисе.


    1. trawl Автор
      20.05.2019 09:01

      Согласен, вариант хорош. Но как тогда проверить, сохранилась ли сущность в БД? Или сохранение тоже вынесено в репозиторий?


      1. Maksclub
        20.05.2019 09:12

        А понял, вы интеграционное делаете тестирование. Тогда наверное ваши подходы подходят.

        Если юнит-делать. Например так: В репозитории сделать метод add(). В оригинальной репе флаш будет, в имитации — просто добавление в приватный массив. Тогда ваш сервис будет тестить я без зависимостей и легко.

        Или так: мокать репу, и мокать ее методы, а сохранение тестировать уже в интеграционном тесте, как сказали ниже.

        Правда в вашем случае может вообще отпасть его надобность, а слову суффикс Service сам на это намекает, что логику бы определить по конкретнее.


      1. oxidmod
        20.05.2019 09:16

        Видели пирамиду тестирования? Чем более изолирован тест — тем он быстрей
        Юниты легкие и их можно написать много, покрывая все ветви бизнес-логики.
        Интеграционные тесты тяжелые и их нужно писать минимум. Как раз покрыть реализацию репозитория, что все методы репозитория работают как надо с тестовой бд. Эта часть тестов будет медленной, но их не много. Дальше в юнит-тестах можно мокать репы спокойно


      1. pbatanov
        20.05.2019 10:46

        Если вам важно именно проверить, что сущности сохранились в БД, то никакие моки не нужно использовать по определению, только «живую» БД. Если вам важно проверить, что вызывается метод сохранения, то в целом неважно, у какого замоканного интерфейса она вызывается — у EM или доменного репозитория.

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


        1. oxidmod
          20.05.2019 10:52

          Я имел ввиду, что не нужно писать интеграционные тесты на бизнес-логику. Это чревато длительными билдами. Потом начинают покрывать только самые очевидные кейсы, потому что каждый кейс — это сетап БД


          1. trawl Автор
            20.05.2019 11:02
            +1

            Но проверка, вызывался ли в сервисе метод сохранения данных — это же не интеграционное тестирование? Или в юнит-тестах нет необходимости проверять этот момент?


            1. OnYourLips
              20.05.2019 11:16

              Юнит-тест проверяет единственный юнит. Если сохранение данных вне его, то проверять не нужно.


              1. trawl Автор
                20.05.2019 11:23

                Т.е. в разрезе юнит-теста я должен был закончить тест-кейс на моменте проверки возвращаемого результата/исключения, а всё остальное — вынести в отдельный тест?


        1. trawl Автор
          20.05.2019 11:00

          Да, я не правильно выразился в высказывании


          Но как тогда проверить, сохранилась ли сущность в БД
          Конечно же, речь шла о проверке вызова метода сохранения.

          В проектах на Symfony, как правило, репозитории используют только для выборки данных, а сохранение осуществляется через EM. Именно такой кейс и рассмотрен в статье