Во многих современных приложениях для работы с базой данных используется проект Doctrine ORM.
Хорошим тоном считается выносить работу с БД в сервисы. А сервисы нужно тестировать.
Для тестирования сервисов можно подключить тестовую базу данных, а можно замокать Менеджер сущностей и репозитории. С первым вариантом всё понятно, но не всегда есть смысл разворачивать БД для тестирования сервиса. Об этом и поговорим.
Для примера, возьмем такой сервис:
<?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. Используем реальную базу данных
Напишем базовый класс для тестов, от которого в последующем будем наследоваться.
<?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
<?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>
Теперь напишем тест сервиса
<?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
// 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
<?xml version="1.0" encoding="UTF-8"?>
<!-- ... -->
<php>
<!-- ... -->
<env name="EMULATE_BD" value="1" />
<!-- ... -->
</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
Maksclub
Вариант 4
Используем в сервисе интерфейс нужного для нашего домена репозитория
Репозитории не создаём в конструкторе, а внедряем снаружи.
Тогда все прекрасно тестируется: делается упрощённая реализация репы и работа идёт с ней в вашем сервисе.
trawl Автор
Согласен, вариант хорош. Но как тогда проверить, сохранилась ли сущность в БД? Или сохранение тоже вынесено в репозиторий?
Maksclub
А понял, вы интеграционное делаете тестирование. Тогда наверное ваши подходы подходят.
Если юнит-делать. Например так: В репозитории сделать метод add(). В оригинальной репе флаш будет, в имитации — просто добавление в приватный массив. Тогда ваш сервис будет тестить я без зависимостей и легко.
Или так: мокать репу, и мокать ее методы, а сохранение тестировать уже в интеграционном тесте, как сказали ниже.
Правда в вашем случае может вообще отпасть его надобность, а слову суффикс Service сам на это намекает, что логику бы определить по конкретнее.
oxidmod
Видели пирамиду тестирования? Чем более изолирован тест — тем он быстрей
Юниты легкие и их можно написать много, покрывая все ветви бизнес-логики.
Интеграционные тесты тяжелые и их нужно писать минимум. Как раз покрыть реализацию репозитория, что все методы репозитория работают как надо с тестовой бд. Эта часть тестов будет медленной, но их не много. Дальше в юнит-тестах можно мокать репы спокойно
pbatanov
Если вам важно именно проверить, что сущности сохранились в БД, то никакие моки не нужно использовать по определению, только «живую» БД. Если вам важно проверить, что вызывается метод сохранения, то в целом неважно, у какого замоканного интерфейса она вызывается — у EM или доменного репозитория.
Тесты как уже написали выше, бывают разными, и ничего не мешает иметь несколько видов тестов сразу.
oxidmod
Я имел ввиду, что не нужно писать интеграционные тесты на бизнес-логику. Это чревато длительными билдами. Потом начинают покрывать только самые очевидные кейсы, потому что каждый кейс — это сетап БД
trawl Автор
Но проверка, вызывался ли в сервисе метод сохранения данных — это же не интеграционное тестирование? Или в юнит-тестах нет необходимости проверять этот момент?
OnYourLips
Юнит-тест проверяет единственный юнит. Если сохранение данных вне его, то проверять не нужно.
trawl Автор
Т.е. в разрезе юнит-теста я должен был закончить тест-кейс на моменте проверки возвращаемого результата/исключения, а всё остальное — вынести в отдельный тест?
trawl Автор
Да, я не правильно выразился в высказывании
В проектах на Symfony, как правило, репозитории используют только для выборки данных, а сохранение осуществляется через EM. Именно такой кейс и рассмотрен в статье