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.
Top comments (8)
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
I had one case like you describe. 3rd party library with hardcoded curl.
I extended that 3rd party class (which contained curl) into my own \Test\Dummy\Dummy Library, rewrote some methods to make them more testable and tested that one instead of original one. I know it's not ideal but it is only working thing I think. And it still does the trick.
Stupidest thing I encountered was when part of original library had methods marked as private and the class was final. 🤦
Hey, very sorry i did not see the comment :(. Yes, many third party libraries do not allow to extend certain parts of its code. Another option is to fork the project on github and make the changes on the forked one.
It would be great if that library code was on Github. :) In my case it is proprietary 3rd party PHP library which is not even on Github. It is being shipped few times a month by some company and I just download it and hope for the green tests. :D So far so good. Of course this is nowhere near perfect, but it works.
What's the library you are using ? I usually use PhpUnit and Codeception