Symfony 5 : Mocking private autowired services in Controller functional tests
Why?
Example use case:
Let’s say you are working on a modern Symfony application where frontend and backend are decoupled. Your application is simply a REST API which communicates with fronted by using JSON payloads. Throwing exceptions in your backend and leaving them unhandled is a bad idea. If your backend encounters an exception, you’ll either end up with a crashed application or transfer the responsibility to frontend to handle the exceptions… but it’s not frontend’s job to do that.
So, you’ll need to catch and process all exceptions in your controller action and return an appropriate error response (if an exception happens). That’s easy… just wrap your code in try / catch
and process exceptions to produce JSON error responses.
Your controller action is probably using some autowired services. These services can throw two types of exceptions:
- Exceptions which can be thrown based on user input (contents of the HTTP
Request
object) - Exceptions which can only be thrown if your application is misconfigured and does not depend on user input (for example: missing runtime environment variables)
Testing the response of the first type of exceptions is easy — you can craft a Request
in your test case to trigger an exception.
But what about testing exception responses which can not be triggered by building a custom Request
object because they don't depend on user input?
Controller sample
//...
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use App\Service\TokenService;class SampleController extends AbstractController
{
//...
private TokenService $tokenService; public function getTokenService(): TokenService
{
return $this->tokenService;
} public function index(): JsonResponse
{
try {
$token = $this->getTokenService()->getToken();
//...
} catch (\Throwable $exception) {
return $this->getResponseProcessingService()->processException($exception);
}
}
//...
}
Let’s assume that getToken()
method of the TokenService
is only capable of throwing exceptions which are not based on user input. In order to cover the catch
code block with a test, you must force this method to throw an exception. That's why we're going to mock it.
Solution
I’m assuming you already know hot to create a functional test for your controller actions. If you don’t, read the official documentation here
Test class
//...
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use App\Service\TokenService;class SampleControllerTest extends WebTestCase
{
public function testIndex()
{
$client = static::createClient();
// Start mocking
$container = self::$container;
$tokenServiceMock = $this->getMockBuilder(TokenService::class)
->disableOriginalConstructor()
->onlyMethods(['getToken'])
->getMock();
$tokenServiceMock->method('getToken')->willThrowException(new \Exception());
$container->set('App\Service\TokenService', $tokenServiceMock);
// End mocking
$client->request('GET', '/sample');
//...
}
//...
}
So, what did we do here?
$client = static::createClient();
and $client->request('GET', '/sample');
are two standard lines of code in almost every functional test in Symfony.
In order to successfully mock an autowired service, we need to create a mock and inject it into the Service Container. That needs to be done after booting the kernel (static::createClient
) and before calling the controller action ($client->request()
).
In addition to this, you’ll also need to declare TokenService
public
in your test environment's service container. To do this, open /config/services_test.yaml
and make your TokenService
public:
App\Service\TokenService:
public: true
All services in Symfony are private by default and unless you have a really good reason, they should stay private on any other environment except test
. We need to make the TokenService
public for our test, because otherwise we would get an exception when trying to set it ($container->set()
) in our test case:
Symfony\Component\DependencyInjection\Exception\InvalidArgumentException : The "App\Service\TokenService" service is private, you cannot replace it.
Summary
- Declare your service as
public
in test environment's service container (/config/services_test.yaml
); - Create a functional test client, which boots the kernel and creates a service container (
static::createClient()
); - Create a mock of your service;
- Replace the default definition of your service with the mock dynamically (
$container->set()
); - Run the client (
$client->request()
)
Originally published at https://dev.to on February 13, 2021.