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:


src / Service / User / UserService.php
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.


tests / TestCase.php
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.xmlin the section php. I will use sqlite db


phpunit.xml
tests/Unitsrc

Now we will write a service test


tests / Unit / Service / UserServiceTest.php
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.


Symfony documentation example
// 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


phpunit.xml changes

Now we modify the base class


modified tests / TestCase.php
 [],
        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


Also popular now: