Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Partial Mocking

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Let's make LockDownHelper more interesting. Let's say that, when a lockdown ends, we need to send an API request to GitHub. In our first tutorial, we wrote code that made API requests to get info about this SymfonyCasts/dino-park repository. Now, we're going to pretend that, when we end a lockdown, we need to send an API request to find all the issues with a "lockdown" label and close them. We're not... actually going to do this, but we'll go through the motions to trigger a fascinating situation.

This Setup: Making API Calls from our Service

In that first tutorial, we made a GitHub service that wraps the API calls. Its one method grabs a health report for the dinosaurs. Add a new public function called clearLockDownAlerts(). Inside, pretend we're making an API call - we don't really need to - then, at least, log a message.

... lines 1 - 9
class GithubService
... lines 12 - 46
public function clearLockDownAlerts(): void
$this->logger->info('Cleaning lock down alerts on GitHub...');
// pretend like this makes an API call to GitHub
... lines 52 - 77

Cool! Also pretend that we've tested this method in some way - via a unit or integration test. The point is: we're confident that this method works.

Over in LockDownHelper, to make our fake API call, autowire GithubService $githubService... and down here, after flush(), say $this->githubService->clearLockDownAlerts().

... lines 1 - 8
class LockDownHelper
public function __construct(
... lines 12 - 13
private GithubService $githubService
... line 18
public function endCurrentLockDown(): void
... lines 21 - 28

Okay! Try the test!

symfony php vendor/bin/phpunit tests/Integration/Service/LockDownHelperTest.php

We haven't changed anything and... it still passes. That makes sense. In our test, we ask Symfony for the LockDownHelper and it handles passing the new GithubService argument when it creates that service. And because GitHubService isn't actually making a real API call, everything is fine.

But what if GithubService did contain real logic to make an HTTP request to GitHub? That could cause a few problems. First, it would definitely slow down our test. Second, it might fail because, when it checks the repository, we may not have any issues with the LockDown label. And third, if it does find issues with that label, it might close them on our real production repository... even though this is just a test.

Furthermore - I know, I'm on a roll - if we wanted to test that the clearLockDownAlerts() method was actually called, in an integration test, the only way to do that is by making an API call from our test to seed the repository with some issues (creating an issue with a LockDown label), calling the method, then making another API request from our test to verify that the issue was closed. Yikes. That's too much work to check something so simple!

Mocking only Some Services?

I hope you're yelling at your computer:

Ryan! This is the whole point of mocking - what we learned in the first tutorial!

Yea, totally! If we mocked GitHubHelper, we would avoid any API calls and have an easy way to assert that the method was called. So, darn, we basically want to mock one dependency... but use the real services for the other dependencies. Is that possible? It is! With something I call "partial mocking".

Injecting a Mock into the Container

When we ask the container for the LockDownHelper service, it instantiates the real services that it needs and passes them to each of the three arguments. What we really want to do is have it pass the real service for $lockDownRepository and $entityManager, but a mock for $githubService. And Symfony gives us a way to do that!

Check it out. Before we ask for LockDownHelperService, create a $githubService mock set to $this->createMock(GitHubService::class). Below that, say $githubService->expects() and, to make sure this fails at first, use $this->never() and ->method('clearLockDownAlerts').

... lines 1 - 12
class LockDownHelperTest extends KernelTestCase
... lines 15 - 16
public function testEndCurrentLockdown()
... lines 19 - 24
$githubService = $this->createMock(GithubService::class);
... lines 28 - 33

If we stop now and run the test:

symfony php vendor/bin/phpunit tests/Integration/Service/LockDownHelperTest.php

It still passes. We created a mock... but no one is using it. We need to tell Symfony:

Hey! Replace the real GitHubService in the container with this mock.

Doing that is simple: self::getContainer()->set() passing the ID of the service, which is GithubService::class, then $githubService.

... lines 1 - 16
public function testEndCurrentLockdown()
... lines 19 - 27
self::getContainer()->set(GithubService::class, $githubService);
... lines 29 - 33
... lines 35 - 36

Suddenly, that becomes the service in the container, and that is what will be passed to LockDownHelper as the third argument.

Try the test!

symfony php vendor/bin/phpunit tests/Integration/Service/LockDownHelperTest.php

Because of the $this->never()... it fails! clearLockDownAlerts() was not expected to be called, but it was... since we're calling it down here. That proves the mock was used!

Change the test from $this->never() to $this->once() and try again...

... lines 1 - 16
public function testEndCurrentLockdown()
... lines 19 - 25
... lines 27 - 33
... lines 35 - 36
symfony php vendor/bin/phpunit tests/Integration/Service/LockDownHelperTest.php

It passes! This is such a cool strategy.

Next: Let's look at how we can test if our code caused certain external things to happen, starting with testing emails.

Leave a comment!

Login or Register to join the conversation
kbond Avatar

By aware there are some edge cases where setting a service on the built test container doesn't work as expected:

  • The container has already created the service when you try and set it (I think)
  • If in a functional test that uses the test client, the container is reset between requests so you'd lose the service you had set

This great library can help with this: https://github.com/Happyr/service-mocking

1 Reply

Hey Kevin!

Excellent point - I was not aware of that library, it seems great. By the way, if your tests are not very complex, you can create a services_test.yaml file and override any service definition that you may need on your functional tests


Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial uses PHPUnit 9 but works just fine for PHPUnit 10.

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "doctrine/doctrine-bundle": "^2.8", // 2.10.2
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
        "doctrine/orm": "^2.14", // 2.16.2
        "symfony/asset": "6.3.*", // v6.3.0
        "symfony/console": "6.3.*", // v6.3.4
        "symfony/dotenv": "6.3.*", // v6.3.0
        "symfony/flex": "^2", // v2.3.3
        "symfony/framework-bundle": "6.3.*", // v6.3.5
        "symfony/http-client": "6.3.*", // v6.3.5
        "symfony/mailer": "6.3.*", // v6.3.5
        "symfony/messenger": "6.3.*", // v6.3.5
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/runtime": "6.3.*", // v6.3.2
        "symfony/security-csrf": "6.3.*", // v6.3.2
        "symfony/twig-bundle": "6.3.*", // v6.3.0
        "symfony/yaml": "6.3.*" // v6.3.3
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
        "phpunit/phpunit": "^9.5", // 9.6.13
        "symfony/browser-kit": "6.3.*", // v6.3.2
        "symfony/css-selector": "6.3.*", // v6.3.2
        "symfony/debug-bundle": "6.3.*", // v6.3.2
        "symfony/maker-bundle": "^1.48", // v1.51.1
        "symfony/phpunit-bridge": "^6.2", // v6.3.2
        "symfony/stopwatch": "6.3.*", // v6.3.0
        "symfony/web-profiler-bundle": "6.3.*", // v6.3.2
        "zenstruck/foundry": "^1.35", // v1.35.0
        "zenstruck/mailer-test": "^1.3", // v1.3.0
        "zenstruck/messenger-test": "^1.7" // v1.7.3