Search code examples
phpunit-testingsymfonyfunctional-testingfosrestbundle

Testing Controllers in Symfony 4.4 while using annotations


I'm facing a problem of testing my Controllers in Symfony 4.4 with FOSRestBundle & JMSSserializer. My controllers are pretty simple, usually containing nothing but call to another services, but I'm using ParamConverter, Serializer, Deserializer etc. I'm never sure if fields returned are the ones I expect.

I want to test how serialization/deserialization is handling my entities. Whenever I add a field in my entities, or change field groups, tests should fail.

Ideally, I would mock my services and call Action directly, but I can't find anywhere, how can I call an Action method all annotations firing.

Is there a way to test other than functional testing whole requests?

Controller action I want to test:

    /**
     * @Rest\Post("/entity")
     * @Rest\Put("/entity/{entityId<\d+>?}")
     * @ParamConverter(name="entity", converter="app.request_body",options={
     *         "deserializationContext"={"groups"={
     *             "DetailsGroup",
     *             "nested"={"IdGroup"},
     *             "owner"={"IdGroup"}
     *         }}
     *     }
     * )
     * @Rest\View(serializerGroups={"IdGroup"}, statusCode=Response::HTTP_CREATED)
     * @param int|null $entityId
     * @param Entity $entity
     * @param ConstraintViolationListInterface $validationErrors
     * @return Entity
     * @throws InvalidArgumentException
     * @throws Exception
     */
    public function setEntityAction(?int $entityId, Entity $entity, ConstraintViolationListInterface $validationErrors): Entity
    {
        if ($validationErrors->count() > 0) {
            throw new InvalidArgumentException('...');
        }

        return $this->entityService->setEntity($entity, $this->getUser());
    }


Solution

  • Testing the controller usually requires a lot of setup when you want to account for the annotations to be covered. Setting this up with a unit test where you just instantiate the controller and mock the called service will not be enough.

    What you can do, is use Symfony's WebTestCase to run a Functional Test that goes through a booted application kernel. This pretty much tests your controller in a setup that resembles closely what will happen when it's actually called in your application. The downside of this is, that it will also run all the services.

    There are a few workarounds you can still try. You could replace the service called in your controller directly in the service container that's being used. Either by changing the container in your test or by providing a custom config/services_test.yaml where you replace the service with a "NullService":

    # config/services_test.yaml
    services:
        App\Service\MyEntityService: # This is the original class name
            class: App\Service\NullService # This is the class name of the "null" service
    

    This way, whenever you inject MyEntityService you will get NullService. This will require you to either extend the original service or have an interface, that both of them can implement. If you have the interface you probably want to use that as service id instead of the original class name.

    The downside of this approach is, that you manually have to wire each service and have to create a dummy replacement for it. The upside is, that you can quite easily return the data you want as you control the implementation.

    Another approach would be to change the container in the test itself:

    protected function testMyController(): void
    {
        $kernel = self::bootKernel();
    
        $mock = $this->createMock(MyEntityService::class);
    
        $kernel->getContainer()->set(MyEntityService::class, $mock);
    }