DEV Community

Nacho Colomina Torregrosa
Nacho Colomina Torregrosa

Posted on

Testing an external api using PHPUnit

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
Enter fullscreen mode Exit fullscreen mode

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
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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)];
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's explore the most important parts:

$httpClient = new MockHttpClient([
    new MockResponse($mockResponse, ['http_code' => 200, 'response_headers' => ['Content-Type: application/json']])
]);

Enter fullscreen mode Exit fullscreen mode

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()
;
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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']);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
milanobrtlik profile image
Milan Obrtlik

This is pretty straightforward. But what to do, when someone is so bright, that he used curl inside the tested method getClimateData?

Collapse
 
icolomina profile image
Nacho Colomina Torregrosa • Edited

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.

Collapse
 
milanobrtlik profile image
Milan Obrtlik

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 :-(

Thread Thread
 
icolomina profile image
Nacho Colomina Torregrosa

Ah ok, I am sorry. I did missed your sarcasm