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.
Top comments (4)
This is pretty straightforward. But what to do, when someone is so bright, that he used curl inside the tested method
getClimateData
?Hey Milan, I think I would create another service which will use Curl to get climate data (let's name it ClimateCurlHandler). Then I would inject ClimateCurlHandler into ClimateHandler (instead of HttpClient). Then, into the test, I would create an stub for ClimateCurlHandler mocking the method which executes the curl so that it would return the response to test. Finally, I would create the stub for ClimateHandler but setting as constructor argument the ClimateCurlHandler stub.
I am sure there are other and better ways to drive your case but I think this would be valid.
You maybe missed my sarcasm there. When i used sentence "is so bright", i meant there is hard dependency, which i am unable to refactor :-(
Ah ok, I am sorry. I did missed your sarcasm