Highly Opinionated Thoughts on Programming

by Elnur Abdurrakhimov

Testing Repositories

Jun 11, 2014

Another common confusion I see in the PHP community is unit testing repositories. Some developers using an ORM like Doctrine mock a query builder and make their mocks expect particular methods calls with particular arguments. Others even try to parse resulting DQL and check it for particular structure. And there are some who go as far as doing the same with the resulting SQL. o_O

In case you missed it, let me repeat it and do it slower. Some developers — okay — using an ORM — okay — and believing that using one will allow them to switch to another database vendor later and without problems — okay — test for the resulting — okay — S-Q-L. What’s more surprising, is that most of those developers think that using a lighter RDBMS like SQLite just for testing is a great idea.

No wonder people whine that TDD is hard, it sucks, it doesn’t work, and, well, it’s dead.

Okay. Enough of facepalming. Now let me try and cure some minds.

First, you should unit test only the code that’s under your control. And you should mock only your own types, even thought sometimes it’s perfectly okay to mock third-party interfaces.

Since repositories are usually chock full of third-party API calls, you can’t really control what code to write — you have to play by the rules of the APIs. Therefore unit testing them doesn’t make much sense.

Second, repositories are at the boundaries of your beautiful application and the ugly outside world. A database is not on the side of your application; it’s outside of it. Repositories are the ones that connect the both worlds. Therefore mocking the APIs of the outside world or the ones connecting with it doesn’t make sense. On the other hand, since repositories’ interfaces belong to you, mocking repositories themselves makes sense.

Third, each database system has its own specific way of communicating with it. If you tie your unit tests to inner workings of a third-party component by basically recreating the functionality of that component in your mocks and feel really great about yourself and how smart you are, I’m happy for you. Just don’t whine that your tests are insanely hard to write, are brittle, and maintaining them is a nightmare.

Fourth, just parsing and testing generated SQL should not give you any confidence that your code works because not everything can be derived from just SQL. You may be missing a required column in a table for SQL to work. A column or a table may have another name — after a database refactoring or for some other reason. Also databases have constraints that won’t get invoked unless you do a real database query.

All that means that not only unit testing repositories is quite a complicated feat, those tests don’t even give you the confidence tests are written to give in the first place. How else would you call it if not masochism?

All in all, to really test your repositories and have confidence in them, you need to do real database queries and test for results. That means you need to write integration tests for your repositories — not unit tests. Here’s an example of doing that:

 1 namespace Example\Repository\Doctrine\ORM;
 3 use Example\Test\AbstractIntegrationTest;
 4 use Example\Util\ModelFactory;
 6 class CompanyRepositoryTest extends AbstractIntegrationTest
 7 {
 8     /**
 9      * @var CompanyRepository
10      */
11     private $companyRepository;
13     protected function setUp()
14     {
15         parent::setUp();
17         $this->companyRepository = $this->getContainer()->get('company_repository');
18     }
20     public function testFindByAdminOrEmployeeEmailDomainWithNoMatchingDomain()
21     {
22         $this->companyRepository->save(ModelFactory::createCompany([
23             'admin' => ModelFactory::createUser([
24                 'email' => 'me@other.com',
25             ]),
26         ]));
28         $this->assertCount(0, $this->companyRepository->findByAdminOrEmployeeEmailDomain('example.com'));
29     }
31     public function testFindByAdminOrEmployeeEmailDomainWithAdminEmailMatching()
32     {
33         $company = $this->companyRepository->save(ModelFactory::createCompany([
34             'admin' => ModelFactory::createUser([
35                 'email' => 'me@example.com',
36             ]),
37         ]));
39         $companies = $this->companyRepository->findByAdminOrEmployeeEmailDomain('example.com');
40         $this->assertCount(1, $companies);
41         $this->assertEquals($company->getId(), $companies[0]->getId());
42     }
44     public function testFindByAdminOrEmployeeEmailDomainWithEmployeeEmailMatching()
45     {
46         $company = $this->companyRepository->save(ModelFactory::createCompany([
47             'admin' => ModelFactory::createUser([
48                 'email' => 'me@other.com',
49             ]),
50             'employees' => [
51                 ModelFactory::createUser([
52                     'email' => 'foo@example.com',
53                 ]),
54             ],
55         ]));
57         $companies = $this->companyRepository->findByAdminOrEmployeeEmailDomain('example.com');
58         $this->assertCount(1, $companies);
59         $this->assertEquals($company->getId(), $companies[0]->getId());
60     }
62     public function testFindByAdminOrEmployeeEmailDomainWithAdminAndSeveralEmployeesEmailsMatching()
63     {
64         $company = $this->companyRepository->save(ModelFactory::createCompany([
65             'admin' => ModelFactory::createUser([
66                 'email' => 'me@example.com',
67             ]),
68             'employees' => [
69                 ModelFactory::createUser([
70                     'email' => 'foo@example.com',
71                 ]),
72                 ModelFactory::createUser([
73                     'email' => 'bar@example.com',
74                 ]),
75             ],
76         ]));
78         $companies = $this->companyRepository->findByAdminOrEmployeeEmailDomain('example.com');
79         $this->assertCount(1, $companies);
80         $this->assertEquals($company->getId(), $companies[0]->getId());
81     }
83     public function testFindByAdminOrEmployeeEmailDomainDoesNotMatchPendingEmployees()
84     {
85         $this->companyRepository->save(ModelFactory::createCompany([
86             'admin' => ModelFactory::createUser([
87                 'email' => 'me@other.com',
88             ]),
89             'pendingEmployees' => [
90                 ModelFactory::createUser([
91                     'email' => 'foo@example.com',
92                 ]),
93             ],
94         ]));
96         $this->assertEmpty($this->companyRepository->findByAdminOrEmployeeEmailDomain('example.com'));
97     }
98 }

To make each test isolated, the parent class clears the database before each test. Note how each test creates only those fixtures it needs, make a call on the repository and checks for results.

The model factory creates model objects with fields prepopulated with random values. This helps keep tests clean and lets me explicitly specify only the fields that are required for a particular test. I plan to cover this technique in a later post.

As you can see, these tests are short, to the point, readable, easy to write and maintain, and do really test what they’re supposed to test. Now go and compare these to your unit tests.

© Elnur Abdurrakhimov