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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLet'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 | |
| $this->githubService->clearLockDownAlerts(); | |
| } | |
| } |
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); | |
| $githubService->expects($this->never()) | |
| ->method('clearLockDownAlerts'); | |
| // ... 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
GitHubServicein 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 | |
| $githubService->expects($this->once()) | |
| // ... 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.
5 Comments
By aware there are some edge cases where setting a service on the built test container doesn't work as expected:
This great library can help with this: https://github.com/Happyr/service-mocking
I was about to say the same thing. I ran into issues when trying to manually set a service in the container during testing. I believe it might happen more often in cases when only one (or any subset) of your tests in a class need to override the service.
I was using the happyr/service-mocking package for a while, but ran into issues regarding services that use the same class, but have different configurations. In my case the affected services were different Flysystem storages.
One possible solution might be to create a proxy (partial mock that falls back to real implementation) service, set it in the container during test setup, and then override the method behavior on it as needed. You can create a proxy with by calling
$this->createTestProxy($className, $constructorArguments)in PHPUnit, where$classNameis the class you want to proxy, and$constructorArgumentsis an array of arguments to pass in the construction of the specified class. I haven't seen this proxy method used often in documentation, blog posts, or SA questions I've looked at in my search for an alternative solution to the problem happyr/service-mocking addresses. I'm not sure why that is the case, because using proxy objects has helped me a lot in writing tests at work.Here is a possible example:
EDIT 2: Regarding Edit 1... According to the documentation overriding private services should work for Symfony 6.x or 7.x. I'm using 5.4, which does not seem to support this.
EDIT 1: After testing, I've found this specific example doesn't work because you can't overwrite a private service and the
upload.storageservice would be private with the default Flysystem config. It seems like this should be something that is allowed in theKernelTestCaseclass since you are allowed to retrieve private services even though that is normally prohibited in non-test code. So, this example would work, but only if the service you are replacing is public :/ My real-world work around currently is just making the proxy object and then manually constructing the depending service (UploadManager in this example), passing in the proxy object and retrieving services from the container as needed.Ok, so it turns out the functionality to create a test proxy (and it's supporting methods) are being deprecated. So... I guess this isn't the way to go :( According to this issue it is being deprecated solely for the fact that the author hasn't seen it used much?
Thanks for the details Joe! My mission this year is to improve the Symfony testing process experience and this is one area that needs improvement.
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.yamlfile and override any service definition that you may need on your functional testsCheers!
"Houston: no signs of life"
Start the conversation!