Replace symfony service in a codeception functional test

I have a love/hate relationship with codeception. When it works, it’s my prince charming on a white horse coming to rescue me from the plight of testing. But ever so often it’s a convoluted and badly documented mess that wrecks havoc to my productivity as I frantically search the net for the one blog post that could help me out. Maybe this can be one those blog posts for somebody else. It’s about how to replace a service (i.e. with a mock) inside the symfony container in a functional test.

Say you have an external service that you call to obtain some data and in one particular case you don’t want to invoke the real service but redirect all calls to a mock that gives a predefined answer, then you need to replace this service inside the symfony container. This is such a common task for me, that I am baffled that there is no $I->replaceServiceWithMock($serviceName, $mockService) method to call. But we can make our own!

Go to your tests folder. There you’ll find _support/Helper/Functional.php. Now open up that file and behold:

// here you can define custom actions
// all public methods declared in helper class will be available in $I

Neat. So I did this:

<?php
/** use .... yada **/
class Functional extends \Codeception\Module { 
    /** 
     * Replace a symfony service with a mock.
     *
     * @param string $serviceClassName
     * @param MockInterface $mockedService
     * 
     * @throws ModuleException
     */ 
    public function replaceServiceWithMock(string $serviceClassName, MockInterface $mockedService)
    {
        /** @var Symfony $symfony */
        $symfony = $this->getModule('Symfony2');
        $symfony->kernel->getContainer()->set($serviceClassName, $mockedService);
    } 
}

Cool. Now let’s see that in action. Maybe you’re testing a service, that has to interact with an external API and you can’t have your test make a real call to that API because that would have real life consequences like ordering 10,000 roles of toilet paper. You could do this:

<?php
/** use .... yada **/
class PlaceOrderCest
{
  /** @var PurchasingService */
  private $purchasingService;
  /** @var MockInterface */
  private $mockedSoapClient;

  public function _before(FunctionalTester $I)
  {
      $this->mockedSoapClient = \Mockery::mock(SoapClient::class);
      $I->replaceServiceWithMock(SoapClient::class, $this->mockedSoapClient);
      $this->purchasingService = $I->grabService(PurchasingService::class);
  }

  public function placeOrder(FunctionalTester $I)
  {
      $I->wantTo('place a new order for 10,000 roles of toilet paper');
      $this->mockedSoapClient->shouldReceive("placeOrder")->withArgs([['item'=>'Toilet Paper', 'amount'=>10000]])->andReturn(true);
      $this->purchasingService->placeOrder('Toilet Paper', 10000);
  }
}

That’s it. If you step through your test with xdebug, you can see that the service has been replaced with your mock. Now you can formulate your expectations and be safe that you don’t accidentally flood your real API with test data.

Update: Unfortunately, it’s not that easy. This approach only works, if you want to test a single service and grab that service from the container the same time that you submit your mock to the container. My example works fine, but as soon as I try to mock a service, that was used in another service, that is used in a controller, that I want to test via the codeception FunctionalTester-methods, the unmocked service pops up again, like a zombie and bites down. Hard. So, what you have to do, is to persist the service in the container:

<?php
...
public function replaceServiceWithMock(string $serviceClassName, MockInterface $mockedService){
        /** @var Symfony $symfony */
        $symfony = $this->getModule('Symfony2');
        $symfony->kernel->getContainer()->set($serviceClassName, $mockedService);
        $symfony->persistService($serviceClassName, false);
    }

To be honest, I am not sure if the second parameter for persistService should be true or false, and the documentation does not help because it doesn’t matter if I set this parameter to true or false, the service is permanently mocked. Which sucks if you happen to run another test directly afterwards and for some reasons you don’t want to use this mock this time. Like me. In order to do that, you have to restore the original service in the container after your done with your tests. I assumed that would happen anyway or that’s what the second parameter is for, but nope. I had to write the antidote for the replaceServiceWithMock method:

<?php
...use and stuff...
class Functional extends \Codeception\Module
{
    private $originalServices = [];
    /**
     * Replace a symfony service with a mock.
     *
     * @param string        $serviceClassName
     * @param MockInterface $mockedService
     *
     * @throws ModuleException
     */
    public function replaceServiceWithMock(string $serviceClassName, MockInterface $mockedService){
        /** @var Symfony $symfony */
        $symfony = $this->getModule('Symfony2');
        $this->originalServices[$serviceClassName] = $symfony->grabService($serviceClassName);
        $symfony->kernel->getContainer()->set($serviceClassName, $mockedService);
        $symfony->persistService($serviceClassName, false);
    }

    /**
     * Replace a mocked service with the originally saved one.
     *
     * @param string $serviceClassName
     *
     * @throws ModuleException
     */
    public function restoreMockedService(string $serviceClassName){
        /** @var Symfony $symfony */
        $symfony = $this->getModule('Symfony2');
        $symfony->kernel->getContainer()->set($serviceClassName, $this->originalServices[$serviceClassName]);
        $symfony->persistService($serviceClassName, false);
    }
}

Now, all that’s left is to add an _after-method to the cest and we are done:

<?php
    public function _after(FunctionalTester $I)
    {
        $I->restoreMockedService(LoggingSoapClient::class);
    }

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.