Reputation: 70
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
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
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:
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