Symfony 4 : Override public services in container
Asked Answered
S

6

14

I am migrating our project to Symfony 4. In my test suites, we used PHPUnit for functional tests (I mean, we call endpoints and we check result). Often, we mock services to check different steps.

Since I migrated to Symfony 4, I am facing this issue: Symfony\Component\DependencyInjection\Exception\InvalidArgumentException: The "my.service" service is already initialized, you cannot replace it. when we redefine it like this : static::$container->set("my.service", $mock);

Only for tests, how can I fix this issue?

Shipman answered 25/7, 2018 at 13:58 Comment(0)
S
4

Finally, I found a solution. Maybe not the best, but, it's working:

I created another test container class and I override the services property using Reflection:

<?php

namespace My\Bundle\Test;

use Symfony\Bundle\FrameworkBundle\Test\TestContainer as BaseTestContainer;

class TestContainer extends BaseTestContainer
{
    private $publicContainer;

    public function set($id, $service)
    {
        $r = new \ReflectionObject($this->publicContainer);
        $p = $r->getProperty('services');
        $p->setAccessible(true);

        $services = $p->getValue($this->publicContainer);

        $services[$id] = $service;

        $p->setValue($this->publicContainer, $services);
    }

    public function setPublicContainer($container)
    {
        $this->publicContainer = $container;
    }

Kernel.php :

<?php

namespace App;

use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    public function getOriginalContainer()
    {
        if(!$this->container) {
            parent::boot();
        }

        /** @var Container $container */
        return $this->container;
    }

    public function getContainer()
    {
        if ($this->environment == 'prod') {
            return parent::getContainer();
        }

        /** @var Container $container */
        $container = $this->getOriginalContainer();

        $testContainer = $container->get('my.test.service_container');

        $testContainer->setPublicContainer($container);

        return $testContainer;
    }

It's really ugly, but it's working.

Shipman answered 29/7, 2018 at 6:48 Comment(0)
T
7

Replacing is deprecated since Symfony 3.3. Instead of replacing service you should try using aliases. http://symfony.com/doc/current/service_container/alias_private.html

Also, you can try this approach:

$this->container->getDefinition('user.user_service')->setSynthetic(true); before doing $container->set()

Replace Symfony service in tests for php 7.2

Transliterate answered 26/7, 2018 at 10:45 Comment(5)
$this->container->getDefinition('user.user_service')->setSynthetic(true); I cannot use this in my tests suites. The compiler is already compiled.Shipman
if you want to replace service in your test code it means that you have something wrong design in your app.Transliterate
it's a functional test, it's a normal behaviour.Shipman
@Transliterate A service used by the app to send mails must not send mails during tests. It must be replaced by a mock. So must any service performing network communication, online payment or producing heavy load such as generating a pdf. Finally, a service must also be replaced by a mock to return controlled error codes to check the app is handling errors correctly. This is normal behavior during tests.Tankard
doesn't work in 4.4Damien
T
6

All the answers to this question seem to overlook the complete history of replacing services in Symfony:

  1. Prior to Symfony 3.2, it was possible to replace services even after they were initialized.
  2. Symfony 3.2 deprecated the ability to replace services with $service->set(), even for test purposes.
  3. After some community feedback about DX in tests with the deprecation, the ability to replace services with $service->set() was restored in 3.3.10, but with the constraint that the service being replaced must not have been "initialized". In other words, the service must not have already been injected into other services (since it's hard for the container to know how to reinitialize the entire graph of all services if such a service is being replaced).

So, if you are getting the The "my.service" service is already initialized, you cannot replace it. message, that means one of two things:

  1. The service you are attempting to replace was already used to initialize another service. You may need to replace the service earlier in your test or rearrange the order in which you are initializing services in your test.
  2. (Not documented anywhere) You are trying to replace a lazy service. Lazy services get a lazy-loading stub injected by the container immediately upon definition, and this causes the container to consider them initialized. As a workaround, define the lazy service as not lazy in your services_test.yaml file. I found this out by stepping through the code in a project that has a lazy service, and this is the workaround I had to use.
Trioxide answered 11/4, 2023 at 5:9 Comment(1)
Just want to confirm the "You may need to replace the service earlier in your test" note. In my case, I was trying to set the mock after I called get on the container. Just moving the mocking before any fetching on the container happens, solved it for meForeordain
S
4

Finally, I found a solution. Maybe not the best, but, it's working:

I created another test container class and I override the services property using Reflection:

<?php

namespace My\Bundle\Test;

use Symfony\Bundle\FrameworkBundle\Test\TestContainer as BaseTestContainer;

class TestContainer extends BaseTestContainer
{
    private $publicContainer;

    public function set($id, $service)
    {
        $r = new \ReflectionObject($this->publicContainer);
        $p = $r->getProperty('services');
        $p->setAccessible(true);

        $services = $p->getValue($this->publicContainer);

        $services[$id] = $service;

        $p->setValue($this->publicContainer, $services);
    }

    public function setPublicContainer($container)
    {
        $this->publicContainer = $container;
    }

Kernel.php :

<?php

namespace App;

use Symfony\Component\HttpKernel\Kernel as BaseKernel;

class Kernel extends BaseKernel
{
    use MicroKernelTrait;

    public function getOriginalContainer()
    {
        if(!$this->container) {
            parent::boot();
        }

        /** @var Container $container */
        return $this->container;
    }

    public function getContainer()
    {
        if ($this->environment == 'prod') {
            return parent::getContainer();
        }

        /** @var Container $container */
        $container = $this->getOriginalContainer();

        $testContainer = $container->get('my.test.service_container');

        $testContainer->setPublicContainer($container);

        return $testContainer;
    }

It's really ugly, but it's working.

Shipman answered 29/7, 2018 at 6:48 Comment(0)
D
1

I've got a couple of tests like this (the real code performs some actions and returns a result, the test-version just returns false for every answer).

If you create and use a custom config for each environment (eg: a services_test.yaml, or in Symfony4 probably tests/services.yaml), and first have it include dev/services.yaml, but then override the service you want, the last definition will be used.

app/config/services_test.yml:

imports:
    - { resource: services.yml }

App\BotDetector\BotDetectable: '@App\BotDetector\BotDetectorNeverBot'

# in the top-level 'live/prod' config this would be 
# App\BotDetector\BotDetectable: '@App\BotDetector\BotDetector'

Here, I'm using an Interface as a service-name, but it will do the same with '@service.name' style as well.

Demission answered 26/7, 2018 at 10:16 Comment(0)
D
1

As I understood it, it means that class X was already injected(because of some other dependency) somewhere before your code tries to overwrite it with self::$container->set(X:class, $someMock).

Damien answered 12/8, 2022 at 15:11 Comment(0)
G
-1

If you on Symfony 3.4 and below you can ovverride services in container regardless it privite or public. Only deprication notice will be emmited, with content similar to error message from question.

On Symfony 4.0 error from the question was thown.

But on Symfony 4.1 and above you can lean on special "test" container. To learn how to use it consider follow next links:

Germaine answered 23/6, 2021 at 15:53 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.