8min.

How to use mock with Symfony’s WebTestCase and PHPUnit

We faced a problematic situation while testing our application, and we may have found a super cool solution. It’s a bit experimental, but we want to share it with you anyway.

Section intitulée the-contextThe context

We have a service that usually makes calls to an external API. It’s a wrapper around the Stripe API, but it doesn’t really matter. It’s a kind of repository class that has many public methods. It looks like this:

namespace App\Billing\Stripe;

class Stripe
{
    public function getProducts(): Collection
    {
        return Product::all(['limit' => 100, 'active' => true]);
    }

    public function generateSignatureHeader(string $payload, int $timestamp = null): string
    {
        $timestamp = time();
        $signedPayload = "{$timestamp}.{$payload}";
        $signature = hash_hmac('sha256', $signedPayload, $this->endpointSecret);

        return "t={$timestamp},v1={$signature}";
    }

    // Many other methods...

For some tests, we want to use this class, and really call the Stripe API. We really want to be sure nothing is broken when a customer wants to subscribe to our service!

But for some other tests, we want to mock this service.

Note: In Symfony 6.3, there is a way to set a service in the DIC in the test environment. But at the time of writing this article, Symfony 6.3 is not released yet. So we have to use a work-around.

Anyway, even with Symfony 6.3, we want to create a mock that is able to fallback on the real implementation for some methods. It means we want to use the real Stripe class for some methods, and mock others. For example, we want to mock only the getProducts() method, but not the generateSignatureHeader() method.

class StripeMock extends Stripe
{
    public function getProducts(): Collection
    {
        // We want a mock here
    }

    // But not for all other methods

PHPUnit has a way to mock a class, and fallback on the real implementation, but it’s really not DX friendly, since you must configure the mock for each method, and especially because you must get all constructor arguments.

$mock = $this
    ->getMockBuilder(Stripe::class)
    ->setConstructorArgs($args) // ...
    ->disableOriginalClone()
    ->disableArgumentCloning()
    ->onlyMethods(['getProduct', 'getFoo', 'getBar', /*… */])
    ->getMock()
;

Section intitulée what-do-we-really-wantWhat do we really want?

We want a system that is able to give us the flexibility to configure from test to test:

  • to use the real service ;
  • to use a mock that fallback on the real service by default on all methods ;
  • to use a mock that fallback on the real service by default on all methods, but with some methods configured.

Section intitulée the-test-caseThe Test Case

We can write some tests :

  1. A test with the default service;
  2. A test with a mock that fallbacks on real implementation;
  3. A test with mock and a configured method.

For the clarity of the article, we will mock the generateSignatureHeader() method:

use App\Billing\Stripe\Stripe;
use App\Util\Test\MockFactory;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class StripeTest extends KernelTestCase
{
    public function testWithDefaultService(): void
    {
        $stripe = self::getContainer()->get(Stripe::class);

        $this->assertInstanceOf(Stripe::class, $stripe);
        $this->assertNotInstanceOf(MockObject::class, $stripe);
        $this->assertSame('t=1680597704,v1=e8b49875cce1262db1e17f6f2fec7582c2fce043bd040ee44e60d7dcb82a3914', $stripe->generateSignatureHeader('payload', '1680597704'));
    }

    public function testWithMockThatFallbackOnRealImplementation(): void
    {
        $mock = $this->createMock(Stripe::class);
        self::getContainer()->get(MockFactory::class)->setMockForService(Stripe::class, $mock);

        $stripe = self::getContainer()->get(Stripe::class);

        $this->assertInstanceOf(Stripe::class, $stripe);
        $this->assertInstanceOf(MockObject::class, $stripe);
        $this->assertSame('t=1680597704,v1=e8b49875cce1262db1e17f6f2fec7582c2fce043bd040ee44e60d7dcb82a3914', $stripe->generateSignatureHeader('payload', '1680597704'));
    }

    public function testWithMockAndConfiguredMethod(): void
    {
        $mock = $this->createMock(Stripe::class);
        $mock->method('generateSignatureHeader')->willReturn('call mocked!');
        self::getContainer()->get(MockFactory::class)->setMockForService(Stripe::class, $mock, ['generateSignatureHeader']);

        $stripe = self::getContainer()->get(Stripe::class);

        $this->assertInstanceOf(Stripe::class, $stripe);
        $this->assertInstanceOf(MockObject::class, $stripe);
        $this->assertSame('call mocked!', $stripe->generateSignatureHeader('payload', '1680597704'));
    }
}

All tests are green. Cool, isn’t it? 🎉 But how to achieve that? What is the MockFactory? So many questions!

Section intitulée the-factoryThe factory

The factory is a class that will return a mock if it has been configured, or the real service otherwise. It will also configure all methods on the mock to call the real implementation, if not already mocked:

namespace App\Util\Test;

use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\DependencyInjection\Attribute\When;

class MockFactory
{
    private array $mocks = [];
    private array $mocksAlreadyMockedMethods = [];

    public function setMockForService(string $serviceId, MockObject $mock, array $alreadyMockedMethods = []): void
    {
        $this->mocks[$serviceId] = $mock;
        $this->mocksAlreadyMockedMethods[$serviceId] = $alreadyMockedMethods;
    }

    // $inner is the real service, the one we want to mock
    public function create(object $inner = null): object
    {
        if (!$inner) {
            throw new \InvalidArgumentException('You must provide an inner service (in the argument of the factory).');
        }

        // The service has not been mocked, we return the real service
        if (!\array_key_exists($inner::class, $this->mocks)) {
            return $inner;
        }

        $mock = $this->mocks[$inner::class];

        $methodsToByPass = get_class_methods($inner::class);
        $methodsToByPass = array_diff($methodsToByPass, $this->mocksAlreadyMockedMethods[$inner::class]);

        foreach ($methodsToByPass as $method) {
            // We cannot mock the constructor
            if ('__construct' === $method) {
                continue;
            }
            // Here we configure the method “$method” to call the real service
            $mock->method($method)->willReturnCallback($inner->{$method}(...));
        }

        return $mock;
    }
}

In the next chapter we’ll see how to wire this Factory in the Dependency Injection Container. We’ll also configure the service we want to mock. Let’s see how to achieve that.

Section intitulée leverage-the-dicLeverage the DIC

Symfony has a nice feature that allows you to replace a service in the DIC by another, in every place where the service is used. It’s called service decoration:

# From the documentation
# config/services.yaml
services:
    App\Mailer: ~

    App\DecoratingMailer:
        decorates: App\Mailer
        # pass the old service as an argument
        arguments: ['@.inner']

And Symfony has another feature that allows you to build a service with a factory: service factories.

# From the documentation
# config/services.yaml
services:
    App\Email\NewsletterManager:
        # the first argument is the class and the second argument is the static method
        factory: ['App\Email\NewsletterManagerStaticFactory', 'createNewsletterManager']

We’ll leverage these two features to create a service that will replace our App\Billing\Stripe\Stripe service, and it will be constructed with a factory. Obviously, this decorator will exist only in the test environment.

# app/config/services.yaml
when@test:
    services:
        # We give a new name for the service by prefixing “test.” to it
        test.App\Billing\Stripe\Stripe:
            # This is the service we want to mock
            decorates: App\Billing\Stripe\Stripe

            # This is the class of the service we want to mock
            class: App\Billing\Stripe\Stripe

            # This is the factory that will return the “real” service, of a configured mock
            factory: ['@App\Util\Test\MockFactory', create]
            arguments:
                # This will inject the “real” App\Billing\Stripe\Stripe service
                - '@.inner'

And that’s all. Now we can mock, or not a service in our test suite:

    public function testWithMockAndConfiguredMethod(): void
    {
        $mock = $this->createMock(Stripe::class, ['generateSignatureHeader']);
        $mock->method('generateSignatureHeader')->willReturn('call mocked!');
        self::getContainer()->get(MockFactory::class)->setMockForService(Stripe::class, $mock);

        $stripe = self::getContainer()->get(Stripe::class);

        $this->assertInstanceOf(Stripe::class, $stripe);
        $this->assertInstanceOf(MockObject::class, $stripe);
        $this->assertSame('call mocked!', $stripe->generateSignatureHeader('payload', '1680597704'));
    }
}

Section intitulée going-furtherGoing further

We can go a bit further with our mocks and factory. We would like to ensure the inner service is not called if we set a mock. If we take the Stripe example, it’ll ensure our application does not emit requests to Stripe. The test will be isolated: Stripe is not required anymore, nor the network. We can be sure the test will stay valid even in two years:

The Factory updated
namespace App\Util\Test;

use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Component\DependencyInjection\Attribute\When;

#[When('test')]
class MockFactory
{
    private array $mocks = [];
    private array $mocksAlreadyMockedMethods = [];
    private array $mocksByPassMethods = [];

    public function setMockForService(
        string $serviceId,
        MockObject $mock,
        array $alreadyMockedMethods = [],
        ?array $byPassMethods = null,
    ): void {
        $this->mocks[$serviceId] = $mock;
        $this->mocksAlreadyMockedMethods[$serviceId] = $alreadyMockedMethods;
        $this->mocksByPassMethods[$serviceId] = $byPassMethods;
    }

    public function create(object $inner = null, string $serviceId = null): object
    {
        if (!$inner) {
            throw new \InvalidArgumentException('You must provide an inner service (in the argument of the factory).');
        }

        $serviceId ??= $inner::class;

        if (!\array_key_exists($serviceId, $this->mocks)) {
            return $inner;
        }

        $mock = $this->mocks[$serviceId];

        $methodsToByPass = $this->mocksByPassMethods[$serviceId] ?? get_class_methods($inner::class);
        $methodsToByPass = array_diff($methodsToByPass, $this->mocksAlreadyMockedMethods[$serviceId]);

        foreach ($methodsToByPass as $method) {
            if ('__construct' === $method) {
                continue;
            }
            $mock->method($method)->willReturnCallback($inner->{$method}(...));
        }

        $methodsToThrowException = get_class_methods($inner::class);
        $methodsToThrowException = array_diff($methodsToThrowException, $this->mocksAlreadyMockedMethods[$serviceId]);
        $methodsToThrowException = array_diff($methodsToThrowException, $methodsToByPass);

        foreach ($methodsToThrowException as $method) {
            if ('__construct' === $method) {
                continue;
            }
            $mock->method($method)->willThrowException(new \RuntimeException(sprintf(
                'Methods "%s::%s()" has been called on the inner service "%s" of a mock and it has been configured to reject it. It looks like you forgot to configure the mock.',
                $inner::class,
                $method,
                $serviceId,
            )));
        }

        return $mock;
    }
}

To make the test more readable, let’s create a new method to configure the Stripe mock:

    private function configureStripe(array $calls): void
    {
        $mock = $this->createMock(StripeInterface::class);

        foreach ($calls as $method => $response) {
            $mock
                ->method($method)
                ->withAnyParameters()
                ->willReturn($response)
            ;
        }

        self::getContainer()
            ->get(MockFactory::class)
            ->setMockForService(
                StripeInterface::class,
                $mock,
                array_keys($calls), // The list of methods already mocked
                ['generateSignatureHeader'], // The list of methods that we want to use on the real implementation
            )
        ;
    }

And now we can use it as follow:

    public function test(): void
    {
        // ...

        $project = $this->createProject(self::SUBSCRIPTION_ID);

        $charges = $chargeRepository->findBy(['project' => $project]);
        $this->assertCount(0, $charges);

        $this->configureStripe([
            'getInvoice' => $this->createStripeObject(Invoice::class, 'charge.failed.response.01.getInvoice'),
            'getSubscription' => $this->createStripeObject(Subscription::class, 'charge.failed.response.02.getSubscription'),
            'getSchedule' => null,
        ]);
        $this->sendRequest('charge.failed');

        $em->clear();

        $this->assertLastCharge($project, 160808, 'failed');
    }

Section intitulée the-caveatsThe caveats

Unfortunately, this comes with a drawback. It’s complicated to replace service in a test between two consecutive requests! Symfony restarts the container between each request, and there is no way to re-configure the container between the shutdown and the new request. We opened an issue to mitigate this issue.

However, if the request / response are stateless, like on an API, we can shut down everything, even the client, and restart everything. Use the following lines before issuing the next request to fix the issue:

static::ensureKernelShutdown();
static::createClient();

Section intitulée conclusionConclusion

Once in place, the system is easy to use. It allows you to create mocks that fallback on the real implementation, and to configure some methods on the mock. It can also detect calls that have not been mocked and throw an exception.

And if you configure nothing, it will fallback to the real service. Exactly what we wanted! 😍.

Please tell us if you have similar needs, and if you have a better solution 🤓.

We think it could be a nice feature to have in Symfony directly! If you think that also, please let us know 😊.

#[AsMockable(env='test')]
class Stripe {
    // …
}

And:

class StripeTest extends KernelTestCase
{
    use MockFactorySetter;

    public function test()
    {
        $mock = $this->createMock(Stripe::class);
        self::setMockForService(Stripe::class, $mock);
        // …
    }
}

Commentaires et discussions

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise