Mocking: Test Doubles
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 SubscribeSo right now, tests are failing because we need to pass a LoggerInterface
instance to the GithubService
inside of our test. We could just create a logger and pass that in. But... That can get a bit hairy. Instantiating a logger object might be simple... but what if it's not? What if we needed to instantiate an object with 5 required constructor args... and some of those are for other objects that are also tricky to create. Chaos!
Fortunately, PHPUnit has our back: with super mocking abilities!
A Mock Logger
Inside the GithubServiceTest
create a $mockLogger
variable set to $this->createMock(LoggerInterface::class)
. Pass this into the GithubService
service.
// ... lines 1 - 7 | |
use Psr\Log\LoggerInterface; | |
class GithubServiceTest extends TestCase | |
{ | |
// ... lines 12 - 14 | |
public function testGetHealthReportReturnsCorrectHealthStatusForDino(HealthStatus $expectedStatus, string $dinoName): void | |
{ | |
$mockLogger = $this->createMock(LoggerInterface::class); | |
$service = new GithubService($mockLogger); | |
// ... lines 20 - 21 | |
} | |
// ... lines 23 - 35 | |
} |
Let's see what happens when we run the tests now.
./vendor/bin/phpunit
And... HA! All of our tests are passing again!
But what is a Mock?
Soo... What is this createMock()
black magic thing that we're using? createMock()
allows us to pass in a class or interface and get back a "fake" instance of that class or interface. This object is called a mock.
Now I already ready know what you're about to ask... What happens to the message when we call the info()
method on the mock LoggerInterface
?
Welp, a whole lotta nothing... Internally, PHPUnit basically creates a fake class that implements LoggerInterface
... except that all of the methods are empty. They do nothing and return nothing.
That is unless we tell it do something different. More on that soon.
By the way, this mock logger is actually called a test double. In fact, we'll run across a few different names for mocks like - test doubles, stubs, and mock objects... All of these names effectively mean the same thing: fake objects that stand in for real ones. There are some subtle differences between the different names and we'll clue you in along the way.
We Should Always Mock Services
We still have one minor problem with our test. Anytime we run it, we're calling the real GitHub API. This is bad mojo... In a unit test, you should never use real services, like API or database calls. Why? The whole point of a unit test is to test that the code inside GithubService
works. And, ideally, we would do that independent of any other layers of our app because... we simply can't control their behavior. For example, what would happen if GitHub's API is offline for maintenance? Or, tomorrow, GenLab changes Daisy
from sick to healthy! Right now, both of those would cause our tests to fail! But they should not! The unit test for GithubService
should only fail if it contains a bug in its code, like it's not parsing the labels correctly.
What's the solution? Mock the HttpClient
.
Refactoring HttpClient to use DependencyInjection
But... we can't do that as long as we're creating the client inside of GitHubService
. Instead, in the constructor, add a private HttpClientInterface $httpClient
argument.
// ... lines 1 - 6 | |
use Symfony\Contracts\HttpClient\HttpClientInterface; | |
class GithubService | |
{ | |
public function __construct(private HttpClientInterface $httpClient, private LoggerInterface $logger) | |
{ | |
} | |
// ... lines 14 - 55 | |
} |
Then call the request()
method on $this->httpClient
instead of $client
. Since we're now using dependency injection, we can remove the static $client
entire, along with the use
statement above.
// ... lines 1 - 8 | |
class GithubService | |
{ | |
// ... lines 11 - 14 | |
public function getHealthReport(string $dinosaurName): HealthStatus | |
{ | |
$health = HealthStatus::HEALTHY; | |
$response = $this->httpClient->request( | |
method: 'GET', | |
url: 'https://api.github.com/repos/SymfonyCasts/dino-park/issues' | |
); | |
// ... lines 23 - 35 | |
} | |
// ... lines 37 - 55 | |
} |
Apart from unit testing, this is just a better way to write your code.
In the test, start by giving the GithubService
an http client without mocking - HttpClient::create()
- just to make sure everything is working as expected.
// ... lines 1 - 8 | |
use Symfony\Component\HttpClient\HttpClient; | |
class GithubServiceTest extends TestCase | |
{ | |
// ... lines 13 - 15 | |
public function testGetHealthReportReturnsCorrectHealthStatusForDino(HealthStatus $expectedStatus, string $dinoName): void | |
{ | |
// ... lines 18 - 19 | |
$service = new GithubService(HttpClient::create(), $mockLogger); | |
// ... lines 21 - 22 | |
} | |
// ... lines 24 - 36 | |
} |
Try the tests:
./vendor/bin/phpunit
And... cool! We didn't break anything...
Mocking the HttpClient
Now we can mock the HttpClient
. Below $mockLogger
add, $mockClient = $this->createMock()
and pass in HttpClientInterface::class
. Now pass this to our service.
// ... lines 1 - 8 | |
use Symfony\Contracts\HttpClient\HttpClientInterface; | |
class GithubServiceTest extends TestCase | |
{ | |
// ... lines 13 - 15 | |
public function testGetHealthReportReturnsCorrectHealthStatusForDino(HealthStatus $expectedStatus, string $dinoName): void | |
{ | |
$mockLogger = $this->createMock(LoggerInterface::class); | |
$mockHttpClient = $this->createMock(HttpClientInterface::class); | |
$service = new GithubService($mockHttpClient, $mockLogger); | |
// ... lines 22 - 23 | |
} | |
// ... lines 25 - 37 | |
} |
Back to the terminal to run our tests:
./vendor/bin/phpunit
And... Oof! Our Sick Dino
test
Failed asserting the two variables are the same
Hmm... For Sick Dino
, we're expecting a HealthStatus::SICK
for Daisy
. In our service, we're calling the request()
method on our mock, making a log entry, then looping over the array that was returned in our response... HA! That's the problem. Remember: whenever PHPUnit creates a mock object, it strips out all the logic for each method within that mock. Yup, we're looping over nothing!
In this case, we need to teach the HttpClient
mock to return a response that contains a matching issue with a Status: Sick
label. That would let us assert that our label-parsing logic is correct.
How do we do that? It's coming up next!