Test doubles are one of the essential pillars of unit testing.
This post and the upcoming ones cover all five test doubles in detail:
Before getting into that, here is why test doubles are necessary.
You need to have PHPUnit up and running on your machine to follow along.
What is test double?
According to Martin Fowler:
A generic term for any case where you replace a production object for testing purposes.
For example, say your application uses an external API for currency conversion:
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 number_format($res->result, 2);
}
}
The question is: how do you test the CurrencyConversion class?
Would it be acceptable to write a unit test that interacts with an external API?
Here is what that looks like:
use App\CurrencyConverstion;
use GuzzleHttp\Client;
use PHPUnit\Framework\TestCase;
class CurrencyConversionTest extends TestCase
{
/** @test */
public function it_converts_currency()
{
$cn = new CurrencyConverstion(new Client());
$amount = $cn->convert('USD', 'DKK', 100);
$this->assertSame(634.461551, $amount);
}
}
Run the test:
phpunit --filter="it_converts_currency"
Result:
Time: 00:00.086, Memory: 20.00 MB
OK (1 test, 1 assertion)
The test passes, but it is fundamentally broken:
- The test is wrong. The exchange rate changes constantly, so the assertion will fail unpredictably.
- You should never call external APIs within unit tests.
- Unit tests should be fast. Calling an external API slows down the testing process.
- You should only test your own code, not the behavior of a third-party API.
The second point deserves extra clarification.
Production data -- such as API tokens -- should not be exposed to the testing environment. The testing environment tests production behavior without affecting production by making unwanted changes.
Furthermore, calling production APIs can be dangerous. Consider an API that charges money (like Stripe) or an OCR API (like Google Vision API) where you pay per page or image.
The answer is to mock the production behavior.
Mocking means replacing a production object (GuzzleHttp\Client) with a testing object.
In this case, you need to know what response the API returns, mock that response, and send it to CurrencyConversion.
Here is the API response:
{
"motd": {
"msg": "If you or your company use this project or like what we doing, please consider backing us so we can continue maintaining and evolving this project.",
"url": "https://exchangerate.host/#/donate"
},
"success": true,
"query": {
"from": "USD",
"to": "DKK",
"amount": 100
},
"info": {
"rate": 6.343743
},
"historical": false,
"date": "2021-08-23",
"result": 634.374255
}
The CurrencyConvertor::convert method relies on the result key (the total converted amount), so all you need to do is send a fake response and assert the predefined value.
But why assert a value you already know? The result value is 634.374255.
Good question. The CurrencyConvertor::convert method uses number_format, so the goal is to test the behavior of number_format applied to the result.
How do we write test doubles?
There are a few PHP libraries for working with test doubles, also called mocking frameworks:
PHPUnit does not support spies or fakes, but the other frameworks do.
Here is how to test CurrencyConvertor using PHPUnit:
use App\CurrencyConverstion;
use GuzzleHttp\Client;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamInterface;
class CurrencyConversionTest extends TestCase
{
public function test_it_converts_currency()
{
$client = $this->createMock(Client::class);
$response = $this->createMock(ResponseInterface::class);
$response
->method('getStatusCode')
->willReturn(200);
$stream = $this->createMock(StreamInterface::class);
$stream
->method('getContents')
->willReturn($this->getJson());
$response
->method('getBody')
->willReturn($stream);
$client
->method('request')
->with('GET', 'https://api.exchangerate.host/convert?from=USD&to=DKK&amount=100')
->willReturn($response);
$currencyConvertor = new CurrencyConverstion($client);
$amount = $currencyConvertor->convert('USD', 'DKK', 100);
$this->assertSame(634.37, $amount);
}
private function getJson(): string
{
return '{"motd":{"msg":"If you or your company use this project or like what we doing, please consider backing us so we can continue maintaining and evolving this project.","url":"https://exchangerate.host/#/donate"},"success":true,"query":{"from":"USD","to":"DKK","amount":100},"info":{"rate":6.343743},"historical":false,"date":"2021-08-23","result":634.374255}';
}
}
Run it:
$ phpunit --filter="it_converts_currency"
PHPUnit 9.5.8 by Sebastian Bergmann and contributors.
. 1 / 1 (100%)
Time: 00:00.009, Memory: 20.00 MB
OK (1 test, 1 assertion)
The test passes. Here is a breakdown of how it works.
Demystify the test case
Start by mocking GuzzleHttp\Client because it is used as a dependency in CurrencyConvertor::__construct:
$client = $this->createMock(Client::class);
By default, all methods in GuzzleHttp\Client are replaced with a dummy implementation that returns null (without calling the original method).
At this point, you have a test double of type Dummy, but it is not useful because all methods return null.
Looking at the CurrencyConvertor::convert method, the request method returns an instance of Psr\Http\Message\ResponseInterface, so you need to mock that as well:
$response = $this->createMock(ResponseInterface::class);
$response
->method('getStatusCode')
->willReturn(200);
The response is now a test double of type Stub.
The practice of replacing an object with a test double that (optionally) returns configured return values is referred to as stubbing. Stubs in PHPUnit.
The difference between Dummies and Stubs is that Stubs return configured data, while Dummies always return null.
In the next post, I will explain Dummies in detail.
The last step is mocking the getBody() method, which returns an instance of Psr\Http\Message\StreamInterface:
$stream = $this->createMock(StreamInterface::class);
$stream
->method('getContents')
->willReturn($this->getJson());
This tells the mocked object to return JSON data whenever getContents() is called.
Then tell the response to return the mocked stream when getBody() is called:
$response
->method('getBody')
->willReturn($stream);
Finally, mock the request method and inject the mocked $client instance:
$client
->method('request')
->with('GET', 'https://api.exchangerate.host/convert?from=USD&to=DKK&amount=100')
->willReturn($response);
$currencyConvertor = new CurrencyConversion($client);
Now test it:
$amount = $currencyConvertor->convert('USD', 'DKK', 100);
$this->assertSame(634.37, $amount);
Summary
- Test doubles -- replacements for production objects used during testing, preventing real API calls, database mutations, and other side effects.
- Why mock -- unit tests should be fast, deterministic, and isolated from external services; calling real APIs introduces flakiness, cost, and security risks.
- Dummies vs Stubs -- Dummies replace an object with one that returns
nullfor all methods; Stubs return configured data for specific methods. - Mocking frameworks -- PHPUnit, Mockery, and Prophecy are the three main options in PHP, each with different levels of support for the five test double types.