Nowadays, many applications need to connect to external resources to perform some operations such as sending an email, syncing data from other platforms etc.
In this article I will show you how to use phpunit test doubles to be able to test an api without having to connect to it. This will ensure your tests will no fail in case api would be unavailable temporary.
We will use climate open api as an external api and symfony http client component as a client to mock.
If you make an get http request to this url:
https://climate-api.open-meteo.com/v1/climate?latitude=40.4165&longitude=-3.7026&start_date=2023-07-09&end_date=2023-07-11&models=CMCC_CM2_VHR4&daily=temperature_2m_max
you will get the following json data as a response:
{
"latitude": 40.40001,
"longitude": -3.699997,
"generationtime_ms": 0.20503997802734375,
"utc_offset_seconds": 0,
"timezone": "GMT",
"timezone_abbreviation": "GMT",
"elevation": 651,
"daily_units": {
"time": "iso8601",
"temperature_2m_max": "°C"
},
"daily": {
"time": [
"2023-07-09",
"2023-07-10",
"2023-07-11"
],
"temperature_2m_max": [
34.8,
35.1,
33.8
]
}
}
In the following sections we will focus our tests on ensuring daily key is set and it contains time and temperature_2m_max data.
Let's start.
The code to test
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ClimateHandler
{
public function __construct(
private readonly HttpClientInterface $httpClient
){ }
public function getClimateData(string $latitude, string $longitude, \DateTimeImmutable $from, \DateTimeImmutable $to): array
{
try{
$response = $this->httpClient->request('GET', 'https://climate-api.open-meteo.com/v1/climate', [
'query' => [
'latitude' => $latitude,
'longitude' => $longitude,
'start_date' => $from->format('Y-m-d'),
'end_date' => $to->format('Y-m-d'),
]
]);
return ['status_code' => $response->getStatusCode(), 'data' => $response->toArray()];
}
catch (HttpExceptionInterface $exception)
{
return ['status_code' => $exception->getResponse()->getStatusCode(), 'data' => $exception->getResponse()->toArray(false)];
}
}
}
As shown in the service above, ClimateHandler makes an HTTP GET request to climate api and returns an array with the response data and the status code. If an error ocurr, it returns the status error code and the response error (which is holded on the exception object).
Mocking external api
The following phpunit class has two tests. One returns a 200 OK response with the right climate data, and another one returns a 400 Bad Request due to some errors.
use App\Application\ClimateHandler;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
class ClimateHandlerTest extends TestCase
{
public function testClimateHandler()
{
$mockResponse = <<<JSON
{
"daily": {
"time": [
"2023-07-09",
"2023-07-10",
"2023-07-11"
],
"temperature_2m_max": [
34.8,
35.1,
33.8
]
}
}
JSON;
$httpClient = new MockHttpClient([
new MockResponse($mockResponse, ['http_code' => 200, 'response_headers' => ['Content-Type: application/json']])
]);
$stub = $this
->getMockBuilder(ClimateHandler::class)
->setConstructorArgs([$httpClient])
->onlyMethods([])
->getMock()
;
$response = $stub->getClimateData(40.416, -3.7026, new \DateTimeImmutable('2023-07-09 00:00:00'), new \DateTimeImmutable('2023-07-12 00:00:00'));
$this->assertEquals(200, $response['status_code']);
$this->assertCount(3, $response['data']['daily']['time']);
$this->assertCount(3, $response['data']['daily']['temperature_2m_max']);
}
public function testClimateHandlerError()
{
$mockResponse = <<<JSON
{"reason":"Value of type 'String' required for key 'end_date'.","error":true}
JSON;
$httpClient = new MockHttpClient([
new MockResponse($mockResponse, ['http_code' => 400, 'response_headers' => ['Content-Type: application/json']])
]);
$stub = $this
->getMockBuilder(ClimateHandler::class)
->setConstructorArgs([$httpClient])
->onlyMethods([])
->getMock()
;
$response = $stub->getClimateData(40.416, -3.7026, new \DateTimeImmutable('2023-07-09 00:00:00'), new \DateTimeImmutable('2023-07-12 00:00:00'));
$this->assertEquals(400, $response['status_code']);
$this->assertTrue($response['data']['error']);
}
}
Let's explore the most important parts:
$httpClient = new MockHttpClient([
new MockResponse($mockResponse, ['http_code' => 200, 'response_headers' => ['Content-Type: application/json']])
]);
Here, we create a MockHttpClient (a class to mock an http client provided by the component. Learn more here) and we instruct it to return the json contained on $mockResponse variable with a 200 OK response code. We also indicate it that response is json encoded setting the content-type header.
$stub = $this
->getMockBuilder(ClimateHandler::class)
->setConstructorArgs([$httpClient])
->onlyMethods([])
->getMock()
;
Now, we create the stub setting our MockHttpClient as a first argument of the constructor. We also use onlyMethods method to tell the stub that no method has to be mocked (we want original method to be executed since we have passed our mocked http client to the constructor ) and gets the stub using getMock.
$response = $stub->getClimateData(40.416, -3.7026, new \DateTimeImmutable('2023-07-09 00:00:00'), new \DateTimeImmutable('2023-07-12 00:00:00'));
$this->assertEquals(200, $response['status_code']);
$this->assertCount(3, $response['data']['daily']['time']);
$this->assertCount(3, $response['data']['daily']['temperature_2m_max']);
Finally, we invoke getClimateData and ensure it makes sense doing the next assertions:
- status_code contains 200
- daily time array has 3 dates
- daily temperature_2m_max array has 3 measures
The other test, testClimateHandlerError, works as testClimateHandler does buy its mock returns a 400 BadRequest http code and the json content is different. If we look on its assertions we can see the following:
$response = $stub->getClimateData(40.416, -3.7026, new \DateTimeImmutable('2023-07-09 00:00:00'), new \DateTimeImmutable('2023-07-12 00:00:00'));
$this->assertEquals(400, $response['status_code']);
$this->assertTrue($response['data']['error']);
In this case, it checks that the status_code is 400 and the error key is true
Its important to notice that there is no matter which parameters we pass to getClimateData since httpClient is mocked and they have no effect.
Conclusion
In this post we've learned how to use de MockHttpClient class of the symfony http client component and the PHPUnit mocking features to test an api call without having to send the call. This is very useful because it allows us to test how our code behaves in the face of the possible api responses without compromising test performance.
Regardless of the context of the project we are working on, it's really important to include a test suite to ensure that the project code behaves as expected. In my recently published book, I show how to test an operation-oriented api. Those tests check various use cases, for instance:
- Checking that an operation is executed successfully
- Checking an authentication error
- Checking an authorization error
- Checking a not allowed operation
- Checking validation error
If you want to know more, you can find the book here.