Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

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.

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

So 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!

Leave a comment!

0
Login or Register to join the conversation
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "symfony/asset": "6.1.*", // v6.1.0
        "symfony/console": "6.1.*", // v6.1.4
        "symfony/dotenv": "6.1.*", // v6.1.0
        "symfony/flex": "^2", // v2.2.3
        "symfony/framework-bundle": "6.1.*", // v6.1.4
        "symfony/http-client": "6.1.*", // v6.1.4
        "symfony/runtime": "6.1.*", // v6.1.3
        "symfony/twig-bundle": "6.1.*", // v6.1.1
        "symfony/yaml": "6.1.*" // v6.1.4
    },
    "require-dev": {
        "phpunit/phpunit": "^9.5", // 9.5.23
        "symfony/browser-kit": "6.1.*", // v6.1.3
        "symfony/css-selector": "6.1.*", // v6.1.3
        "symfony/phpunit-bridge": "^6.1" // v6.1.3
    }
}
userVoice