Using Mocks with PHPUnit

Creating test doubles, or mocks, is a feature of PHPUnit that I recently discovered and am quickly falling in love with. Previously I would find other ways to mock my system, like creating SQLite connections instead of a persistent database, or even duplicating an entire mock class for injection. PHPUnit comes with a mock interface that is easy to use and offers a much deeper level of assertion than I thought possible.

In order to take advantage of this feature it's important to have code with injected dependencies. Even a container can be used - simply wire up the mocks instead of the real dependencies and let the tested object eat mocks. If the code to be tested is instantiating new objects within methods, it's going to be more difficult to test and, well, is a bit smelly.

Here's an example of a class using dependency injection.

  1. class SomeModel

  2. {

  3. public function __construct(Db $db)

  4. {

  5. $this->db = $db;

  6. }

  7. public function getEntities()

  8. {

  9. $query = 'SELECT * FROM `users`';

  10. return $this->db->fetchAll($query);

  11. }

  12. }

This class is ready to roll with some mocks. The 'Db' object that is being used by the 'getEntities' method is injected right in the construct. In the test code we'll use a mock version of 'Db', which also happens to prevent the test from actually hitting anything real (or erroring out due to a bad connection).

  1. // first attempt

  2. class SomeModelTest extends PHPUnit_Framework_TestCase

  3. {

  4. public function testGetEntities()

  5. {

  6. $mockDb = $this->getMock(Db::class);

  7. $someModel = new SomeModel($mockDb);

  8. $entities = $someModel->getEntities();

  9. }

  10. }

Well, huh. This doesn't actually test anything. Well, it does ensure that the method doesn't error out when it's called, but that's not terribly useful. We want to actually verify that the response of 'Db::fetchAll()' is returned from this method. By default all methods of a mocked class will return 'null', so we should override that in our next attempt.

  1. // second attempt

  2. class SomeModelTest extends PHPUnit_Framework_TestCase

  3. {

  4. public function testGetEntities()

  5. {

  6. $mockDb = $this->getMock(Db::class);

  7. $mockDb->method('fetchAll')

  8. ->willReturn([ 'an array' ]);

  9. $someModel = new SomeModel($mockDb);

  10. $entities = $someModel->getEntities();

  11. $this->assertEquals([ 'an array' ], $entities);

  12. }

  13. }

This is better - now we actually have something we can assert on. We tell the mocked class method what to return, and then verify that the response holds true. We can do more, though. In our class we have a couple of interesting chunks. First, we define a query that gets passed to the 'Db::fetchAll()' method. Second, the 'Db::fetchAll()' method gets called once and only once. We can fine-tune our mock to test both of these behaviors.

  1. // final attempt

  2. class SomeModelTest extends PHPUnit_Framework_TestCase

  3. {

  4. public function testGetEntities()

  5. {

  6. $mockDb = $this->getMock(Db::class);

  7. $mockDb->expects($this->once())

  8. ->method('fetchAll')

  9. ->with($this->equalTo('SELECT * FROM `users`'))

  10. ->willReturn([ 'an array' ]);

  11. $someModel = new SomeModel($mockDb);

  12. $entities = $someModel->getEntities();

  13. $this->assertEquals([ 'an array' ], $entities);

  14. }

  15. }

Here we go - now this, this is pretty sweet. We set up the mock class, tell it that the method 'fetchAll' should get called once, test the expected parameters, and then set the response. If any of these don't happen during this test method than the test will fail.

There is some more complicated things that PHPUnit's mocks can do, such as consecutive calls returns, conditional handling, or even inject callbacks for the responses. One of my projects is using the ServiceLocator pattern (I know, I know), and it's still totally mockable because I can mock the container with conditional returns based on a map. You can read more about mocks on PHPUnit's documentation.