Working with partial mocks in PHPUnit 10

Victor Pryazhnikov
Bumble Tech
8 min readJul 29, 2021

--

PHPUnit 10 is due out this year (the release was planned for 2 April 2021 but has been delayed). If you look at the list of changes, you’ll see that a lot of obsolete code has been removed. One such change is the removal of the MockBuilder::setMethods() method, which was actively used when working with partial mocks. This method has been deprecated since PHPUnit 8.0 but it’s still documented without any alternatives or any reference to its undesirability. If you read the PHPUnit source code, issues and pull requests on GitHub and you’ll see why this is the case and what the alternatives are.

In this article I cover this nuance for those who perhaps haven’t considered it before: I talk about partial mocks, problems with setMethods and how to solve them, and how to migrate the tests to PHPUnit 10.

What are partial mocks?

The program code we write the most often has certain dependencies.

When we write unit tests we isolate these dependencies by substituting stub objects with predefined states rather than real objects. This allows us to test just one bit of code at a time. These stubs are most often implemented using mocks.

The essence of a mock is that instead of a dependency object, you use a special object in which all the methods of the original class have been replaced. For such an object, you can configure the results returned by the methods and add checks for method calls.

PHPUnit has a built-in mechanism for working with mocks. One of its features is to create so-called partial mocks, where the original class behaviour is not completely replaced, but only for individual methods. Such mocks are extremely useful when you want to write a test for one method in particular and, in the process, call other methods (that you don’t want to test).

Let’s look at an example of where such mocks can be useful.

Here is the code for the base class that implements the “command” pattern:

abstract class AbstractCommand
{
/**
* @throws \PhpUnitMockDemo\CommandException
* @return void
*/
abstract protected function execute(): void;
public function run(): bool
{
$success = true;
try {
$this->execute();
} catch (\Exception $e) {
$success = false;
$this->logException($e);
}
return $success;
}
protected function logException(\Exception $e)
{
// Logging
}
}

The actual behaviour of the command is specified in the execute method of the derived classes, and the run() method adds behaviour common to all commands (in this case, making the code exception safe and logging errors).

If we want to write a test for the run method, we can use partial mocks, the functionality of which is provided by the PHPUnit\Framework\MockObject\MockBuilder class, accessed via TestCase class methods (in the example these are getMockBuilder and createPartialMock):

use PHPUnit\Framework\TestCase;class AbstractCommandTest extends TestCase
{
public function testRunOnSuccess()
{
// Arrange
$command = $this->getMockBuilder(AbstractCommand::class)
->setMethods(['execute', 'logException'])
->getMock();
$command->expects($this->once())->method('execute');
$command->expects($this->never())->method('logException');
// Act
$result = $command->run();
// Assert
$this->assertTrue($result, "True result is expected in the success case");
}
public function testRunOnFailure()
{
// Arrange
$runException = new CommandException();
// It's an analogue of $this->getMockBuilder(...)->setMethods([...])->getMock()
$command = $this->createPartialMock(AbstractCommand::class, ['execute', 'logException']);
$command->expects($this->once())
->method('execute')
->will($this->throwException($runException));
$command->expects($this->once())
->method('logException')
->with($runException);
// Act
$result = $command->run();
// Assert
$this->assertFalse($result, "False result is expected in the failure case");
}
}

Source code, test run results

In the testRunOnSuccess method, we use MockBuilder::setMethods() to specify the list of methods of the original class that we replace (whose calls we want to check or whose results we want to commit). All other methods retain their implementation from the original AbstractCommand class (and their logic can be tested). In testRunOnFailure we do the same thing via the createPartialMock method, but explicitly.

In this example it’s simple enough: we specify the methods we want to override and in the test, we check whether they are called or not via expects. In real code, there are other cases that require method overrides:

  • Preparing or releasing some resources (e.g. database connections)
  • External calls that slow down the tests and pollute the environment (sending queries to the database, reading from or writing to the cache, etc.)
  • Sending some debugging information or statistics.

Often there are simply no call checks for such cases (because they are not always needed and make the tests fragile when the code is changed).

Besides overriding existing methods, MockBulder::setMethods() allows new methods to be added to the mock class which are not in the original class. This can be useful when using the “magic” __call method in the code under test.

Take the \Predis\Client class as an example. It uses the __call method to handle commands sent to the client. This looks like a specific method call in the external code and it seems natural to override this method call in the mock we create, rather than overriding __call by going into implementation details.

Example:

    public function testRedisHandle()
{
if (!class_exists('Redis')) {
$this->markTestSkipped('The redis ext is required to run this test');
}
$redis = $this->createPartialMock('Redis', ['rPush']); // Redis uses rPush
$redis->expects($this->once())
->method('rPush')
->with('key', 'test');
$record = $this->getRecord(Logger::WARNING, 'test', ['data' => new \stdClass, 'foo' => 34]); $handler = new RedisHandler($redis, 'key');
$handler->setFormatter(new LineFormatter("%message%"));
$handler->handle($record);
}

Source: RedisHandlerTest from monolog 2.2.0

What problems arise when using setMethods?

Two responsibilities of setMethods() can lead to problems.

If there are overridden methods in a mock without values, then if you rename or delete them, the test continues (although the method no longer exists and there is no point in adding it to the mock).

Here’s a brief demonstration. To the code of our command class let’s add how long it took to execute it:

--- a/src/AbstractCommand.php
+++ b/src/AbstractCommand.php
@@ -13,6 +13,7 @@ abstract class AbstractCommand
public function run(): bool
{
+ $this->timerStart();
$success = true;
try {
$this->execute();
@@ -21,6 +22,7 @@ abstract class AbstractCommand
$this->logException($e);
}
+ $this->timerStop();
return $success;
}
@@ -28,4 +30,14 @@ abstract class AbstractCommand
{
// Logging
}
+
+ protected function timerStart()
+ {
+ // Timer implementation
+ }
+
+ protected function timerStop()
+ {
+ // Timer implementation
+ }
}

Source code

Let’s now add new methods to the test code in the mock, but not check the calls through expectations:

--- a/tests/AbstractCommandTest.php
+++ b/tests/AbstractCommandTest.php
@@ -11,7 +11,7 @@ class AbstractCommandTest extends TestCase
{
// Arrange
$command = $this->getMockBuilder(AbstractCommand::class)
- ->setMethods(['execute', 'logException'])
+ ->setMethods(['execute', 'logException', 'timerStart', 'timerStopt']) // timerStopt is a typo
->getMock();
$command->expects($this->once())->method('execute');
$command->expects($this->never())->method('logException');

Source code, test run results

If you run this test in PHPUnit version 8.5 or 9.5, it will pass successfully without any warnings:

PHPUnit 9.5.0 by Sebastian Bergmann and contributors..                                                                   1 / 1 (100%)Time: 00:00.233, Memory: 6.00 MBOK (1 test, 2 assertions)

Of course, this is a very simple example, in which it’s not difficult to add expectations for new methods. In real code, things can be more complicated, and I’ve stumbled across nonexistent methods in mocks more than once.

It’s even harder to keep track of such problems when using MockBuilder::setMethodsExcept, which overrides all but the specified class methods.

How is this problem solved in PHPUnit 10?

The move towards solving this problem of implicit overriding of non-existent methods began in 2019 in pull request #3687, which was included in the PHPUnit 8 release.

MockBuilder has two new methods — onlyMethods() and addMethods() — which divide the setMethods() responsibilities into parts. onlyMethods() can only replace methods that exist in the original class, and addMethods() can only add new ones (which do not exist in the original class).

In the same PHPUnit 8 setMethods was marked as deprecated and a warning appeared when passing non-existent methods to TestCase::createPartialMock().

If you take the previous example with an incorrect method name and use createPartialMock instead of calling getMockBuilder(…)->setMethods(…), the test will pass, but a warning will appear about a future change in this behaviour:

createPartialMock() called with method(s) timerStopt that do not exist in PhpUnitMockDemo\AbstractCommand. This will not be allowed 
in future versions of PHPUnit.

Unfortunately, this change was not reflected in the documentation — only setMethods() was still described there, and everything else was hidden in the depths of code and GitHub.

PHPUnit 10 has radically solved the setMethods() problem: setMethods and setMethodsExcept have been permanently removed. This means that if you use them in your tests and want to upgrade to the new version of PHPUnit, you need to remove all use of these methods and replace them with onlyMethods and addMethods.

How to migrate partial mocks from old tests to PHPUnit 10?

In this part, I share some tips on how this can be done.

Let me say right away that you don’t have to wait for PHPUnit 10 to come out and upgrade to it in order to use these tips. You can do all this while working with tests that run in PHPUnit 8 or 9.

Wherever possible, replace MockBuilder::setMethods() calls with onlyMethods()

This might seem quite obvious, but in many cases, it will suffice. I recommend replacing all occurrences and dealing with crashes. These can be caused partly by the problems described above (in which case you should either remove the method from the mock or use its actual name), and partly by using “magic” in the mock class.

Use MockBuilder::addMethods() for classes with ‘magic methods’

If the method you want to override in the mock works through the “magic” __call method, then use MockBuilder::addMethods().

If you used to use TestCase::createPartialMock() for classes with “magic” and it worked, this breaks in PHPUnit 10. Now createPartialMock only knows how to replace existing methods of the class it’s mocking, and you need to replace the use of createPartialMock with getMockBuilder()->addMethods().

If you are creating mocks for external libraries, study their changes or be as specific as possible about the version

In tests that use mocks of classes from external libraries, things can be more complicated because the dependency version can change. This is especially true when you’re using the lowest versions of dependencies along with stable ones in CI.

Here is an example from the PhpAmqpLib library.

Suppose you need a mock for the \PhpAmqpLib\Channel\AMQPChannel class.

In version 2.4 there was a __destruct method that sent an external request (and therefore should be mocked).

In version 2.5 this method has been removed and you no longer need to mock it.

If the dependency in composer.json is spelt like this: “php-amqplib/php-amqplib”: “~2.4”, then both versions will fit (but you need different mocks for them) and you will need to determine which one of the two is used.

This can be done in a number of ways:

  • By fixing the version of the library as much as possible (e.g. in the example above, you could use ~2.4.0 — and then the difference would only be in the patch versions)
  • By checking the library version or method availability (but this is a bad way of doing it because it requires careful examination of code changes of all libraries used, and it looks very much like some kind of hack)
  • By using full mocks for classes from external libraries rather than partial mocks (but this is not always possible).

Conclusion

Partial mocks are a very useful tool for writing unit tests. Unfortunately, figuring out their changes in the PHPUnit documentation is not easy at all. I hope that this article will help, and make your future migration to the new version a little easier.

Also, check out:

--

--