Testing an external api using PHPUnit
Tags: php, testing, api, phpunit
Published: 2023-07-11 14:21:38

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](https://phpunit.de/manual/6.5/en/test-doubles.html#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](https://open-meteo.com/en/docs/climate-api) as an external api and symfony [http client](https://symfony.com/doc/current/http_client.html) 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:

```json
{
  "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

```php
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.

```php
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:

```php
$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](https://symfony.com/doc/current/http_client.html#testing)) 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.

```php
$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*.

```php
$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:

```php
$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](https://www.amazon.com/dp/B0D79KGL7P).