RolandD
RolandD

Reputation: 70

Unit testing a Symfony Service Class

I am looking for some guidance on how to write a unit test for Symfony Service class. All day hunting down the web but what I mostly find is outdated questions and answers regarding old phpunit versions and old Symfony versions.

I am running Symfony 4 and have a service class called ApiService.php. This class connects to an external API service, I am not looking at testing this external API service but rather my own methods with a fixed dataset.

A very towned down version of the class is like this and lives in the folder src/Service/ApiService.php:

<?php

namespace App\Service;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Uri;
use JsonException;

class ApiService
{
    /**
     * Set if test environment is enabled
     *
     * @var    bool
     * @since  1.0.0
     */
    private bool $test;

    /**
     * User key for API authentication
     *
     * @var    string
     * @since  1.0.0
     */
    private string $userKey;

    /**
     * Construct the class.
     *
     * @param   bool    $test  Set API mode
     * @param   string  $key   Set the API token
     *
     * @since   1.0.0
     */
    public function __construct(bool $test, string $key)
    {
        $this->userKey = $key;
        $this->test    = $test;
    }

    /**
     * Search companies.
     *
     * @param   array  $params     Parameters to filter the query on
     * @param   array  $companies  List of retrieved companies
     *
     * @return  array  List of companies.
     *
     * @since   1.0.0
     * @throws  JsonException
     * @throws  GuzzleException
     */
    public function getCompanies(array $params, array $companies = []): array
    {
        $results = $this->callApi('search/companies', $params);

        if (isset($results['data']['items'])) {
            $companies = array_merge(
                $companies,
                $results['data']['items']
            );
        }

        $nextLink = $results['data']['nextLink'] ?? null;

        if ($nextLink) {
            $uri = new Uri($nextLink);
            parse_str($uri->getQuery(), $params);
            $companies = $this->getCompanies($params, $companies);
        }

        return $companies;
    }

    /**
     * Call the API.
     *
     * @param   string  $destination  The endpoint to call
     * @param   array   $params       The parameters to pass to the API
     *
     * @return  array  API details.
     *
     * @since   1.0.0
     * @throws  GuzzleException|JsonException
     */
    private function callApi(string $destination, array $params = []): array
    {
        $client = new Client(['base_uri' => 'https://test.com/']);

        if ($this->test) {
            $destination = 'test' . $destination;
        }

        if ($this->userKey) {
            $params['user_key'] = $this->userKey;
        }

        $response = $client->get($destination, ['query' => $params]);

        return json_decode(
            $response->getBody()->getContents(),
            true,
            512,
            JSON_THROW_ON_ERROR
        );
    }
}

Here is the test class I have ended up with so far but it does not work:

<?php

namespace App\Tests\Service;

use App\Service\ApiService;
use PHPUnit\Framework\TestCase;

class ApiServiceTest extends TestCase
{
    public function testGetCompanies()
    {
        $result = ['data' => [
            'items' => [
                1 => 'first',
                2 => 'second'
            ]
        ];

        $apiService = $this->getMockBuilder(ApiService::class)
            ->disableOriginalConstructor()
            ->getMock();
        $apiService->method('callApi')
            ->with($result);

        $result = $apiService->getCompanies([]);

       print_r($result);
    }
}

What I failing to understand is a couple of things.

First which class I should extend:

Second, how do I setup mock data so I am not going the external API but rather pass in the $result I have defined.

As mentioned earlier, I am not looking to test the external API but rather that my methods always behave as designed in the test given the sample data to test with.

Any tips will be greatly appreciated.

Upvotes: 2

Views: 2271

Answers (2)

Nico Haase
Nico Haase

Reputation: 12094

In my projects, it helps to inject the HttpClient into the services, for example using HttpClientInterface $httpClient in the constructor. Afterwards, you have a exchangable client in that service and stop creating it within your service.

A pretty simple test case can then look the following. It checks whether an API request is done after all, nothing more:

public function testRequestIsExecuted(): void
{
    $callbackWasCalled = false;

    $callback = function ($method, $url, $options) use (&$callbackWasCalled) {
        $callbackWasCalled = true;
        return new MockResponse('[]');
    };

    $mockedClient = new MockHttpClient($callback);

    $apiService = new Apiservice($mockedClient);
    $result = $apiService->getCompanies([]);

    $this->assertTrue($callbackWasCalled);
}

You want to do more detailed checks? No problem, just fiddle around with the parameters of your callback: you can compare the method type (GET/POST), return different reponses based on the URL that is called,....

Upvotes: 0

Philip Weinke
Philip Weinke

Reputation: 1844

You should extend from PHPUnit's TestCase. WebTestCase and KernelTestCase are useful if you want to make functional tests. Your case is a classic unit test: you want to test your ApiService in isolation.

The ApiService is actually doing two things at the moment:

  • Making the calls
  • Processing the data

It's a good idea to separate one from another by introducing a dedicated API Client:

interface ApiClient
{
    public function call(string $destination, array $params = []): array;
}

For your production code, you can create an implementation using Guzzle. You can write integration tests for the GuzzleApiClient that make actual http requests, to ensure that it handles the responses in the expected ways.

Your ApiService is now boiled down to this:

final class ApiService
{
    private ApiClient $apiClient;

    public function __construct(ApiClient $apiClient)
    {
        $this->apiClient = $apiClient;
    }

    public function getCompanies(array $params, array $companies = []): array
    {
        $results = $this->apiClient->call('search/companies', $params);

        if (isset($results['data']['items'])) {
            $companies = array_merge(
                $companies,
                $results['data']['items']
            );
        }

        $nextLink = $results['data']['nextLink'] ?? null;

        if ($nextLink) {
            parse_str(parse_url($nextLink, PHP_URL_QUERY), $params);

            $companies = $this->getCompanies($params, $companies);
        }

        return $companies;
    }
}

Since I don't know what the ApiService does exactly, I made up these example tests:

/**
 * @covers \App\Service\ApiService
 */
class ApiServiceTest extends TestCase
{
    /**
     * @var MockObject&ApiClient
     */
    private ApiClient $apiClient;

    private ApiService $subject;

    public function testGetCompanies()
    {
        $this->apiClient->addResponse(
            'search/companies',
            [],
            ['data' => ['items' => [1 => 'first', 2 => 'second']]]
        );

        $result = $this->subject->getCompanies([]);

        self::assertEquals(['first', 'second'], $result);
    }

    public function testGetCompaniesPaginated()
    {
        $this->apiClient->addResponse(
            'search/companies',
            [],
            ['data' => ['items' => [1 => 'first', 2 => 'second'], 'nextLink' => 'search/companies?page=2']]
        );
        $this->apiClient->addResponse(
            'search/companies',
            ['page' => 2],
            ['data' => ['items' => [1 => 'third', 2 => 'fourth'], 'nextLink' => 'search/companies?page=3']]
        );
        $this->apiClient->addResponse(
            'search/companies',
            ['page' => 3],
            ['data' => ['items' => [1 => 'fifth']]]
        );


        $result = $this->subject->getCompanies([]);

        self::assertEquals(['first', 'second', 'third', 'fourth', 'fifth'], $result);
    }

    protected function setUp(): void
    {
        parent::setUp();

        $this->apiClient = new class implements ApiClient {
            private array $responses = [];

            public function call(string $destination, array $params = []): array
            {
                return $this->responses[$this->key($destination, $params)] ?? [];
            }

            public function addResponse(string $destination, array $params, array $response)
            {
                $this->responses[$this->key($destination, $params)] = $response;
            }

            private function key(string $destination, array $params): string
            {
                return $destination . implode('-', $params);
            }
        };

        $this->subject = new ApiService($this->apiClient);
    }
}

I created an anonymous class for the ApiClient implementation. This is just an example. You can - of course - also use PHPUnit's mocks, Prophecy or whatever mocking framework you like. But I found that it's often easier to create specialized test doubles.

Upvotes: 2

Related Questions