Simple API mocking with the Symfony HTTP client

Published on 2022-02-12 • Modified on 2022-02-12

In this post, we see a simple solution to mock API calls with the Symfony HTTP client, declare a scoped HTTP client and test it with or without mock. Let's go! 😎

» Published in "A week of Symfony 791" (21-27 February 2022).

Prerequisite

I will assume you have at least a basic knowledge of Symfony and you know how to test an application with PHPUnit (check my last article on the subject).

Configuration

  • PHP 8.3
  • Symfony 6.4.5
  • PHPUnit 9.5.26

Introduction

In my previous article, we saw how to organize the tests of a Symfony application. We had a particular category, "external", making actual HTTP request on the network. But what if we don't want to make network calls to run the test offline? We can use what we call "mocks". That means that can we simulate a real response with fake data that respect the structure of the original data source.

Goal

The goal is to use a mock instead of an actual HTTP call in the test environment. We will use a simple solution avoiding using an external dependency.

An "external" test

First, let's have a look at an external test:

<?php

declare(strict_types=1);

namespace App\Tests\External\Controller;

use App\Controller\WhatIsMyIpAction;
use App\Tests\WebTestCase;

final class WhatsMyIpActionTest extends WebTestCase
{
    /**
     * @see WhatIsMyIpAction
     */
    public function testWhatIsMyIpActionEn(): void
    {
        $client = self::createClient();
        $client->enableProfiler();
        $client->request('GET', '/en/tools/what-is-my-ip');
        self::assertResponseIsSuccessful();
        $content = (string) $client->getResponse()->getContent();
        self::assertStringContainsString('Your IP is:', $content);
        self::assertStringContainsString('300.300.300.300', $content);
    }
}

This test is straightforward; it's a "smoke" test; we only test if the page works, doesn't return a 500 error and if it shows the user's IP. The action behind it is classic; it's glue code calling the service to get the user's IP and passing it to the Twig template. Let's have a look at the service in charge of collecting the data from the external API:

<?php

declare(strict_types=1);

namespace App\Utility;

use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
 * @see https://app.abstractapi.com/api/ip-geolocation/documentation
 */
class AbstractApi
{
    public function __construct(
        private HttpClientInterface $abstractApiClient,
        private string $abstractApiKey)
    {
    }

    // Snippet L15+12 used in templates/blog/posts/_64.html.twig

    /**
     * @return array<string, mixed>
     */
    public function getData(string $ip): array
    {
        $query = [
            'api_key' => $this->abstractApiKey, // your secret API key
            'ip_address' => $ip,                // if IP is not set, it uses the one of the current request
        ];

        return $this->abstractApiClient->request('GET', '/v1', ['query' => $query])->toArray();
    }
}

Some notes about this service; we inject one service and a parameter. As we can see, the service implements the HttpClientInterface. The Symfony component generates it. There is only one function, it prepares the request to send with setting the IP to use, and the secret key to be identified (passing a secret key as a GET parameter isn't really a good practice, in this case, it would be better in a header for example). Let's see how the HTTP client is configured.

Configuring the HTTP client

To be able to inject the HTTP client service, it must be declared; this is done in the config/packages/framework.yaml file:

framework:
    # https://symfony.com/doc/current/components/http_client.html#symfony-framework-integration
    http_client:
        default_options:
            max_redirects: 5
        scoped_clients:
            # Specialized client to consume the AbstractAPI
            abstract.api.client:
                timeout: 10
                base_uri: 'https://ipgeolocation.abstractapi.com'
                headers:
                    'Accept': 'application/json'
                    'Content-Type': 'application/json'
                    'User-Agent': 'strangebuzz.com-v%app_version%'

We use a scoped client; this means that the client is bound to a given URL. When using it, we only have to deal with relative URLs of endpoints and forget about the protocol and domain. When declaring this configuration, the Symfony HTTP component does several interesting things. It creates a service for each declared client, so they are ready to be used. But how does it work if we have several clients? Well, cherry-on-the-cake, Symfony creates named parameters for each scoped clients. They are ready to inject into our services. It's exactly what we did in our service: private HttpClientInterface $abstractApiClient. No need for extra configuration. And as you can see, we set several headers to tell that we consume the API with JSON thanks to the Accept and Content-Type keys. I also set a specific user agent, but it's not mandatory.

The most important is that we inject a HttpClientInterface in our service. This means that we respect the Liskov substitution principle, and we can replace the client with any object implementing the HttpClientInterface. This is what we are going to do in the test environment.


Barbara Liskov
Barbara Liskov

Creating the HttpInterface mock

We don't have to reinvent the wheel; the HTTP component already contains such a class: the MockHttpClient. Let's look at its declaration (thank you, Nicolas and other contributors, for the component by the way, πŸ™‚):

/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas <p@tchwork.com>
 */
class MockHttpClient implements HttpClientInterface, ResetInterface
{
    use HttpClientTrait;

Perfect! As expected, this class implements the HttpClientInterface. We can use it to create our new mock. Let's add the src/Tests/Mock/AbstractApiMock.php file. This class, therefore, extends MockHttpClient:

<?php

declare(strict_types=1);

namespace App\Tests\Mock;

use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
use Symfony\Component\HttpFoundation\Response;

final class AbstractApiMock extends MockHttpClient
{
    private string $baseUri = 'https://api.example.com';

    public function __construct()
    {
        $callback = \Closure::fromCallable([$this, 'handleRequests']);

        parent::__construct($callback, $this->baseUri);
    }

    private function handleRequests(string $method, string $url): MockResponse
    {
        if ($method === 'GET' && str_starts_with($url, $this->baseUri.'/v1')) {
            return $this->getV1Mock();
        }

        throw new \UnexpectedValueException("Mock not implemented: $method/$url");
    }

    /**
     * "/v1" endpoint.
     */
    private function getV1Mock(): MockResponse
    {
        $mock = [
            'ip_address' => '300.300.300.300',
            'city' => 'Paris',
            'flag' => [
                'emoji' => 'πŸ‡«πŸ‡·',
            ],
        ];

        return new MockResponse(
            json_encode($mock, JSON_THROW_ON_ERROR),
            ['http_code' => Response::HTTP_OK]
        );
    }
}

Some explanations: we use a specific base URI, the default value is https://example.com, but we can use whatever we want here. The handleRequests() function does most of the job; it's responsible for identifying a request and returning the corresponding mock. Here, we only handle one endpoint /v1 with the GET method. We create an array that respects the original API, and we encode it as JSON. It's, in fact a subset of the actual response because I only use these three fields in the Twig template. It allows the mock to be as small as possible. But it's also nice to have a complete response as a reference to know what information to use later in our application. Our new mock is ready to use; let's see how to activate it in the test environment.

Using the mock in the test environment

Because of Symfony's beautiful configuration and environment system, replacing one service with another for a given environment is straightforward. Open or create your config/services_test.yaml with the following content:

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\Tests\Mock\AbstractApiMock:
        decorates: 'abstract.api.client'
        decoration_inner_name: 'App\Tests\Mock\AbstractApiMock.abstract.api.client'
        arguments: ['@.inner']

Yes, it's as easy as this. We decorate the abstract.api.client service with the mock we just created. In the next chapter, we'll see the reason behind setting the optional parameters decoration_inner_name and arguments in a next chapter. Let's run the test for now:

./vendor/bin/phpunit --filter=testWhatIsMyIpActionEn
PHPUnit 9.5.13 by Sebastian Bergmann and contributors.

Testing
.                                                                   1 / 1 (100%)

Time: 00:00.317, Memory: 52.50 MB

OK (1 test, 2 assertions)

Notice the execution time. Now let's uncomment the mock in the config/services_test.yaml file and rerun the test:

PHPUnit 9.5.13 by Sebastian Bergmann and contributors.

Testing
.                                                                   1 / 1 (100%)

Time: 00:01.105, Memory: 54.50 MB

OK (1 test, 2 assertions)

The execution time is over one second! It's because an actual HTTP call is made to the API instead mocks. Thanks to the profiler, let's improve the tests by checking the HTTP calls.

First, we enable the profiler. Then we can retrieve the information for the HTTP Client collector:

final class WhatIsMyIpActionTest extends WebTestCase
{
    /**
     * @see WhatIsMyIpAction
     */
    public function testWhatIsMyIpActionEn(): void
    {
        $client = self::createClient();
        $client->enableProfiler();
        $client->request('GET', '/en/tools/what-is-my-ip');
        self::assertResponseIsSuccessful();
        $content = (string) $client->getResponse()->getContent();
        self::assertStringContainsString('Your IP is:', $content);
        self::assertStringContainsString('300.300.300.300', $content);

        /** @var HttpProfile $profile */
        $profile = $client->getProfile();
        self::assertInstanceOf(HttpProfile::class, $profile);
        /** @var HttpClientDataCollector $httpClientCollector */
        $httpClientCollector = $profile->getCollector('http_client');
        // self::assertSame(1, $httpClientCollector->getRequestCount()); // @checkme 0 after dep update
        self::assertSame(0, $httpClientCollector->getErrorCount());
    }

The first part of the test is identical to the old one except we call $client->enableProfiler(); to enable the profiler. If we don't $client->getProfile(); returns null and the next assertInstanceOf fails. After, we test that a request is made (even a fake one), and no error is encountered.

But wait? How to be sure that the mock is really used? The execution time isn't proof. Well, that's why we use a non valid IP address 300.300.300.300, because it can only returned by the mock. But let's see, how to create an integration test to check the original HTTP client is indeed decorated:

<?php

declare(strict_types=1);

namespace App\Tests\Integration\Tests\Mock;

use App\Tests\Mock\AbstractApiMock;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

final class AbstractApiMockTest extends KernelTestCase
{
    /**
     * Test that the "abstract.api.client" service is decorated by the mock.
     */
    public function testAbstractApiMockDecoration(): void
    {
        // this is the service ID of the HTTP client in the DI container
        $abstractApiClientId = 'abstract.api.client';

        // standart service (as it is in the prod env)
        self::assertTrue(self::getContainer()->has($abstractApiClientId));

        // decorated service
        self::assertTrue(self::getContainer()->has(AbstractApiMock::class.'.'.$abstractApiClientId));
    }
}

Some explanations. We test that the original service exists; it's the standard one that is used in the dev or prod environment. Then we check the "inner" service injected in the mock when decorating. That's why, when we configured the App\Tests\Mock\AbstractApiMock service in config/services_test.yaml, we set the decoration_inner_name and arguments parameters to use the same service id string, in this case, abstract.api.client. You probably noticed that the mock doesn't accept an argument in its constructor. That's right; it's because we don't need it. So I ignore it. But if we don't set arguments, the inner service wouldn't exist, and the test would fail. OK, so our test works. But how can we verify if it really tests what we want? We can comment the App\Tests\Mock\AbstractApiMock service declaration and rerun the test:

make test filter=AbstractApiMockTest
PHPUnit 9.5.13 by Sebastian Bergmann and contributors.

Testing
F                                                                   1 / 1 (100%)

Time: 00:02.076, Memory: 105.00 MB

There was 1 failure:

1) App\Tests\Integration\Tests\Mock\AbstractApiMockTest::testAbstractApiMockDecoration
Failed asserting that false is true.

strangebuzz.com/tests/Integration/Tests/Mock/AbstractApiMockTest.php:24

FAILURES!
Tests: 1, Assertions: 2, Failures: 1.

The second assertion that test the inner service fails and therefore really tests what we expected πŸŽ‰.

And a little quiz to finish, who said:


β€œPro tip: Stop mocking everything in unit tests… By doing so, you are not testing anything anymore. ”


Click here to see the answer

Conclusion

We saw a simple solution to use HTTP client mocks in Symfony tests. Remember that using mocks has a cost. You assume that your third-party vendor API doesn't change. If it changes, your tests will pass, but your website will break in production! If you want something more robust and if you need to manage much more mocks and different responses for the same resource, you probably want to use a mock server like Mockserver or Wiremock. But let's see this in another article (or not).

That's it! I hope you like it. Check out the links below to have additional information related to the post. As always, feedback, likes and retweets are welcome. (see the box below) See you! COil. 😊

  Read the doc  More on the web

They gave feedback and helped me to fix errors and typos in this article; many thanks to alanpoulain. πŸ‘

  Work with me!


Call to action

Did you like this post? You can help me back in several ways: (use the Tweet on the right to comment or to contact me )

Thank you for reading! And see you soon on Strangebuzz! πŸ˜‰

COil