
PHPUnit. Mocking Doctrine Entity Manager
- Tutorial
Many modern database applications use the Doctrine ORM project .
It’s considered good practice to take the work with the database to the services. And services need to be tested.
For testing services, you can connect a test database, or you can lock the Entity Manager and repositories. With the first option, everything is clear, but it does not always make sense to deploy a database to test the service. We’ll talk about this.
For example, take the following service:
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;
}
}
We need to test his only method create()
.
Select the following cases:
- Successful user creation without referrer
- Successful user creation with referrer
- Error "Login already taken"
- Error "Referrer not found"
To test the service, we need an object that implements the interface Doctrine\ORM\EntityManagerInterface
Option 1. We use a real database
We will write a base class for tests, from which we will later inherit.
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;
}
}
Now it makes sense for the tests to set environment variables. Add them to the file phpunit.xml
in the section php
. I will use sqlite db
tests/Unit src
Now we will write a service test
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);
}
}
Make sure that our service is working properly
./vendor/bin/phpunit
Option 2. Using MockBuilder
Building a database every time is hard. Moreover phpunit gives us the opportunity to collect moki on the fly using mockBuilder. An example can be seen in the Symfony documentation.
// 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));
}
}
The option is working, but there are problems. You need to clearly know in what sequence the code accesses the EntityManager methods.
For example, if a developer swaps the check for the existence of a referrer and a check for busy login, the test will break. But the application is not.
I propose the option of smart moking EntityManager, which stores all its data in memory and does not use a real database.
Option 3. We use MockBuilder with data storage in memory.
For flexibility, add an environment variable so that you can use a real database. We make wintering inphpunit.xml
Now we modify the base 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);
};
}
}
Now we can run the test again and make sure that our service works without connecting to the database.
./vendor/bin/phpunit
Source Code Available on Github