PHPUnit test HTTP request

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.1
  • Symfony 5.4.20
  • 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:

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:


     */
    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


     */
    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();
    }
}
2. 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


     */
    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();
    }
}
3 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:


     */
    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();
    }
}
4. 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

     */
    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();
    }
}
5 and

     */
    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();
    }
}
6 keys. I also set a specific user agent, but it's not mandatory.

The most important is that we inject a


     */
    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();
    }
}
2 in our service. This means that we respect the Liskov substitution principle, and we can replace the client with any object implementing the

     */
    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();
    }
}
2. This is what we are going to do in the test environment.


PHPUnit test HTTP request

Barbara Liskov

Creating the HttpInterface mock

We don't have to reinvent the wheel; the HTTP component already contains such a class: the


     */
    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();
    }
}
9. 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 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

Perfect! As expected, this class implements the


     */
    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();
    }
}
2. We can use it to create our new mock. Let's add the
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%'
1 file. This class, therefore, extends

     */
    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();
    }
}
9:

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

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%'
3, but we can use whatever we want here. The
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%'
4 function does most of the job; it's responsible for identifying a request and returning the corresponding mock. Here, we only handle one endpoint
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%'
5 with the
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%'
6 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

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%'
7 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

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%'
8 service with the mock we just created. In the next chapter, we'll see the reason behind setting the optional parameters
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%'
9 and
/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

0 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

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%'
7 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());
        self::assertSame(0, $httpClientCollector->getErrorCount());
    }

The first part of the test is identical to the old one except we call

/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

2 to enable the profiler. If we don't
/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

3 returns
/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

4 and the next
/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

5 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

/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

6, 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:


     */
    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();
    }
}
0

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

/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

7 service in
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%'
7, we set the
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%'
9 and
/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

0 parameters to use the same service id string, in this case,
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%'
8. 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
/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

0, 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
/**
 * A test-friendly HttpClient that doesn't make actual HTTP requests.
 *
 * @author Nicolas Grekas 

*/ class MockHttpClient implements HttpClientInterface, ResetInterface { use HttpClientTrait;

7 service declaration and rerun the test:


     */
    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();
    }
}
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. 😊

 More on the web

They gave feedback and helped me to fix errors and typos in this article; many thanks to alanpoulain. 👍

How do I run a test in PHPUnit?

To run your tests, execute the vendor/bin/phpunit or php artisan test command from your terminal:.
namespace Tests\Unit;.
use PHPUnit\Framework\TestCase;.
class ExampleTest extends TestCase..
* A basic test example..
* @return void..
public function test_basic_test().

What is PHPUnit testing?

PHPUnit is a unit testing framework for the PHP programming language. It is an instance of the xUnit design for unit testing systems that began with SUnit and became popular with JUnit. Even a small software development project usually takes hours of hard work.

How to test REST API in PHP?

You can test your REST API with GET, POST, PUT, PATCH, and DELETE request methods, send JSON data, and custom HTTP headers..
Enter the REST API URL for testing;.
Select the HTTP method;.
Specify a set of header;.
Set the required body content;.
Send data to start the test..

How do I test a post request?

Here are some tips for testing POST requests:.
Create a resource with a POST request and ensure a 200 status code is returned..
Next, make a GET request for that resource, and ensure the data was saved correctly..
Add tests that ensure POST requests fail with incorrect or ill-formatted data..