Test doubles in PHP - Dummies and Stubs
A dummy is an object that replaces all the original methods with a dummy implementation that returns null
(without calling the original method).
We can easily create a dummy object using the createMock
method in PHPUnit:
use GuzzleHttp\Client;
$client = $this->createMock(Client::class);
Let's inspect the dummy object using the dd($client);
helper:
Mock_Client_8cd31d5b {#129
-config: null
-__phpunit_originalObject: null
-__phpunit_returnValueGeneration: true
-__phpunit_invocationMocker: null
}
As you might have noticed, the createMock
creates a random class; In our case it was named Mock_Client_8cd31d5b
. The Mock_Client_8cd31d5b
extends GuzzleHttp\Client
class:
dd(
class_parents($this->createMock(Client::class)),
);
Output:
array:1 [
"GuzzleHttp\Client" => "GuzzleHttp\Client"
]
Additionally, it overrides all the original methods, so they return null
:
$client = $this->createMock(Client::class);
// This line returns null
dd(
$client->get('https://example.com')->getBody()
);
Please notice that the createMock
replaces all the objects recursively, this means that the get
method will return a new dummy implementation of the Psr\Http\Message\ResponseInterface
.
So, now we know what dummies are, but how would we use them?
Let's make some changes into the CurrencyConvertor
class as follows:
<?php
namespace App;
use Exception;
use GuzzleHttp\Client;
class CurrencyConversion
{
public function __construct(private Client $client)
{
}
public function convert(string $from, string $to, int|float $amount): float
{
$query = http_build_query([
'from' => $from,
'to' => $to,
'amount' => $amount,
]);
$req = $this
->client
->request('GET', 'https://api.exchangerate.host/convert?'.$query);
if ($req->getStatusCode() !== 200) {
throw new Exception('Could not convert!');
}
$res = json_decode($req->getBody()->getContents());
if (json_last_error() !== JSON_ERROR_NONE) {
throw new Exception('Invalid response!');
}
return $this->formatNumber($res->result);
}
public function formatNumber(float $amount, int $decimals = 2): float
{
return (float) number_format($amount, $decimals);
}
}
As you might have noticed, I moved out the number_format
in its own method.
Let's see how do we test the formatNumber
method:
public function test_format_number()
{
$client = $this->createMock(Client::class);
$currencyConvertor = new CurrencyConversion($client);
$this->assertEqualsWithDelta(
10.99,
$currencyConvertor->formatNumber(10.999)
, 0.1);
}
The CurrencyConvertor::__construct
requires an instance of GuzzleHttp\Client
but it doesn't use it unless we call the convert
method.
Stubs
The practice of replacing an object with a test double that (optionally) returns configured return values is referred to as stubbing. PHPUnit.
In the previous post, we mocked different objects, for example the response object:
$response = $this->createMock(ResponseInterface::class);
$response
->method('getStatusCode')
->willReturn(200);
This is called stubbing because we're configuring an object that returns a predefined value when it gets called.
Conclusion
The difference between Dummies and Stubs lies in the return value; Dummy always returns null
, whereas Stub returns a predefined value.
PHPUnit supports a bunch of stubbing methods such as willReturnArgument
, willReturnSelf
, willThrowException
, consider instructing the documentation if you want to know more about them.