Tags:
Typically if you find yourself in a situation where a method needs to make repeated calls to a single other method with varying values, you will look for a way to implement this into some form of batch call. This simplifies it into a single call that takes a single object, list, or array. Sometimes though this isn't possible, perhaps because the method being called belongs to an external class that can't be changed.
Contents
I ran into this situation building a class that wrapped a cURL call, that needed to make multiple calls to setOpt()
which would change depending on factors like the request verb and supplied data parameters.
The problem I had was when I came to write unit tests with PHPUnit.
Testing Called Methods
Consider the following PHP code which needs to be covered by tests:
class FooBar
{
public function setOpt(string $key, $value)
{
// do something with arguments here
}
}
$fooBarInstance = new FooBar();
$fooBarInstance->setOpt('key 1', 'value 1');
$fooBarInstance->setOpt('key 2', 'value 2');
Normally, in PHPUnit you can test that a method was called with specific arguments with this:
$mockUnderTest->expects($this->once())
->method('setOpt')
->with('key 1', 'value 1');
When only a single method is called this works as expected, so it you would probably assume you can duplicate the expectation again with the different values to test a second call:
$mockUnderTest->expects($this->once())
->method('setOpt')
->with('key 1', 'value 1');
$mockUnderTest->expects($this->once())
->method('setOpt')
->with('key 2', 'value 2');
When you run the test, however, you'll recieve an error that your method under test was expecting to be called once with a set of values, but was never called! But why?
Testing The Same Method Multiple Times
The reason the assertions fail is because of how PHPUnit processes the invocation matchers. It begins by testing the first matcher against the values of the first call in the class being tested, which will match the first call. Then it proceeds to try testing against the second matcher, which will fail, because it's still testing against the first set of values in the code under test.
There are different ways to test multiple calls to a single method, depending on what we're trying to test.
Test Return Values with a Map
When you want to want to test the output of a method when given specific inputs you can use a ReturnValueMap
. For example:
class Average
{
public function mean(Array $values)
{
return array_sum($values) / count($values);
}
}
// ...
$average->expects($this->any())
->method('mean')
->with(
$this->returnValueMap([
[[1, 2, 3], 2],
[[1, 4, 8, 2], 3.75],
[[5, 5, 5, 100], 28.75],
])
);
In the case here it works well, because the method call does return a value, but what about methods that don't return anything, or that always return the same thing (as in the case of chainable methods)?
Testing Method Calls with At()
The at()
method allows us to test calls to a method based on the specific index it's called. For example, if our setOpt()
method in the code under test is called exactly 3 times, we can us at()
to test each invocation using the indexes 0, 1, and 2 (indexes start at 0).
$mockUnderTest->expects($this->at(0))
->method('setOpt')
->with('key 1', 'value 1');
$mockUnderTest->expects($this->at(1))
->method('setOpt')
->with('key 2', 'value 2');
$mockUnderTest->expects($this->at(2))
->method('setOpt')
->with('key 3', 'value 3');
Testing like this is very brittle though, as it must match very specifically the order of calls being made in your code. For this reason, the at()
matcher has actually been deprecated, and it's generally advised to avoid using it for new tests.
Test Multiple Return Values with willReturnOnConsecutiveCalls()
An often suggested replacement for the deprecated at()
matcher is to use willReturnOnConsecutiveCalls()
to test consecutive return values for the method being tested.
$mockUnderTest->expects($this->exactly(3))
->method('setOpt')
->willReturnOnConsecutiveCalls(
'value 1',
'value 2',
'value 3'
);
This does suffer from the same issue that at()
does in that it ties the test very specifically to the order of calls being made to the method in your code, but as it's not deprecated then we can use it safely knowing that it won't be removed in the near future.
It does have one more problem however, which is that it doesn't allow testing the caller arguments and depends on the method returning a value.
Testing Method Arguments with a Callback
If you want to test what that a method was called with specific arguments but don't care about a value being returned, you can use a callback. Here I'm using one to check that setOpt()
is always called with a specific value when given a specific key.
$mockUnderTest->expects($this->any())
->method('setOpt')
->will(
$this->returnCallback(function($key, $value){
$argPairs = [
'key 1' => 'value 1',
'key 2' => 'value 2',
'key 3' => 'value 3',
];
if(array_key_exists($key, $argPairs))
{
$this->assertEquals($argPairs[$key], $value);
}
})
);
The main disadvantage with this approach is that if your tested method is ever called with extra argument pairs that aren't on the list or isn't called with an argument pair on the list then the test won't fail. You could alter the callback to keep track of the calls being made and make a final assert that the expected arguments and only those are being called.
Conclusion
Testing multiple calls to a single method isn't always as straightforward as it first appears, but there are options deopending on exactly what you want to test. These aren't the only options, as PHPUnit is pretty powerful and provides a lot of different tools that allow you to approach testing in a variety of different ways.
I like the callback option, as it's incredibly powerful, but probably too complex for the majority of tests you will be writing.
Comments