Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Mocking: Mock Objects

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

Our tests are passing, the dino's are wandering, and life is great! But... let's think about this for a second. In GithubService, when we test getHealthReport(), we're able to control the $response that we get back from request() by using a stub. That's great, but it might also be nice to ensure that the service is only calling GitHub one time and that it's using the right HTTP method with the correct URL. Could we do that? Absolutely!

Expect a Method to Be Called

In GithubServiceTest where we configure the $mockHttpClient, add ->expects(), and pass self::once().

... lines 1 - 11
class GithubServiceTest extends TestCase
{
... lines 14 - 16
public function testGetHealthReportReturnsCorrectHealthStatusForDino(HealthStatus $expectedStatus, string $dinoName): void
{
... lines 19 - 36
$mockHttpClient
->expects(self::once())
... lines 39 - 40
;
... lines 42 - 45
}
... lines 47 - 59
}

Over in the terminal, run our tests...

./vendor/bin/phpunit

Expecting Specific Arguments

And... Awesome! We've just added an assertion to our mock client that requires the request method be called exactly once. Let's take it a step further and add ->with() passing GET... and then I'll paste the URL to the GitHub API.

... lines 1 - 11
class GithubServiceTest extends TestCase
{
... lines 14 - 16
public function testGetHealthReportReturnsCorrectHealthStatusForDino(HealthStatus $expectedStatus, string $dinoName): void
{
... lines 19 - 36
$mockHttpClient
->expects(self::once())
->method('request')
->with('GET', 'https://api.github.com/repos/SymfonyCasts/dino-park')
->willReturn($mockResponse)
;
... lines 43 - 46
}
... lines 48 - 60
}

Try the tests again...

./vendor/bin/phpunit

And... Huh! We have 2 failures:

Failed asserting that two strings are equal

Hmm... Ah Ha! My copy and paste skills are a bit weak. I missed /issue at the end of the URL. Add that.

... lines 1 - 11
class GithubServiceTest extends TestCase
{
... lines 14 - 16
public function testGetHealthReportReturnsCorrectHealthStatusForDino(HealthStatus $expectedStatus, string $dinoName): void
{
... lines 19 - 36
$mockHttpClient
... lines 38 - 39
->with('GET', 'https://api.github.com/repos/SymfonyCasts/dino-park/issues')
... line 41
;
... lines 43 - 46
}
... lines 48 - 60
}

Let's see if that was the trick:

./vendor/bin/phpunit

Umm... Yes! We're green all day. But best of all, the tests confirm we're using the correct URL and HTTP method when we call GitHub.

But... What if we actually wanted to call GitHub more than just once? Or... we wanted to assert that it was not called at all? PHPUnit has us covered. There are a handful of other methods we can call. For example, change once() to never().

And watch what happens now:

./vendor/bin/phpunit

Hmm... Yup, we have two failures because:

request() was not expected to be called.

That's really nifty! Change the expects() back to once() and just to be sure we didn't break anything - run the tests again.

./vendor/bin/phpunit

And... Awesome!

Carefully Applying Assertions

We could call expects() on our $mockResponse to make sure that toArray() is being called exactly once in our service. But, do we really care? If it's not being called at all, our test would certainly fail. And if it's being called twice, no big deal! Using ->expects() and ->with() are great ways to add extra assertions... when you need them. But no need to micromanage how many times something is called or its arguments if that is not so important.

Using GitHubService in our App

Now that GithubService is fully tested, we can celebrate by using it to drive our dashboard! On MainController::index(), add an argument: GithubService $github to autowire the new service.

... lines 1 - 5
use App\Service\GithubService;
... lines 7 - 10
class MainController extends AbstractController
{
#[Route(path: '/', name: 'main_controller', methods: ['GET'])]
public function index(GithubService $github): Response
{
... lines 16 - 30
}
}

Next, right below the $dinos array, foreach() over $dinos as $dino and, inside say $dino->setHealth() passing $github->getHealthReport($dino->getName()).

... lines 1 - 5
use App\Service\GithubService;
... lines 7 - 10
class MainController extends AbstractController
{
#[Route(path: '/', name: 'main_controller', methods: ['GET'])]
public function index(GithubService $github): Response
{
... lines 16 - 23
foreach ($dinos as $dino) {
$dino->setHealth($github->getHealthReport($dino->getName()));
}
... lines 27 - 30
}
}

To the browser and refresh...

And... What!

getDinoStatusFromLabels() must be HealthStatus, null returned

What's going on here? By the way, the fact that our unit test passes but our page fails can sometimes happen and in a future tutorial, we'll write a functional test to make sure this page actually loads.

The error isn't very obvious, but I think one of our dino's has a status label that we don't know about. Let's peek back at the issues on GitHub and... HA! "Dennis" is causing problems yet again. Apparently he's a bit hungry...

In our HealthStatus enum, we don't have a case for Hungry status labels. Go figure. Is a hungry dinosaur accepting visitors? I don't know - I guess it depends on if you ask the visitor or the dino. Anyways, Hungry is not a status we expected. So next, let's throw a clear exception if we run into an unknown status and test for that exception.

Leave a comment!

9
Login or Register to join the conversation
Yuuki-K Avatar

Within the context of this test, aren't we testing implementation instead of behavior if we check that only one request is made?

Reply
Jesse-Rushlow Avatar
Jesse-Rushlow Avatar Jesse-Rushlow | SFCASTS | Yuuki-K | posted 9 months ago

Howdy!

Sort of... In order for GithubService to work, it needs to make the API call to GitHub. To do that, we tell PHPUnit that hey, request() must be called X number of times - in our case, once(). If we didn't have the excepts() assertion, then the test would still pass as seen in the previous chapter.

The same applies to the with() assertion - if request() is called with anything other than GET && https://api.github.com/repos/SymfonyCasts/dino-park the test will fail.

In most cases, when you mock and configure a service (or any other object), you want to ensure that the method is called and has the required arguments, if any. Another way to think about it - if we were going to deploy this app to production and the cost of making API calls was a factor - having a test in place to ensure that we're are calling the correct API and only calling it once, would be pretty crucial...

I hope you're enjoying the series and if I misunderstood your question, please let us know!

Thanks!

Reply

Since the client is mocked, I don't understand how it validates that it uses GET with the correct URL. Can you please explain?

Reply

Hey Julien,

Good question! The easiest way would be to change GET with POST in the test and run the test - you should see it that it will fail this time. So, it's happening in PHPUnit, with the with() method you tell PHPUnit to make sure that the method is called with the exact arguments you're passing in the test, i.e. with('GET', 'https://api.github.com/repos/SymfonyCasts/dino-park') and PHPUnit take care of the rest, i.e. validates that the args are exactly like you mentioned and throws if they are not :)

So, in short, PHPUnit validates it on the mocked object, not a real object.

I hope it's clearer for you now.

Cheers!

Reply
Ruslan Avatar

Something does not work: - The media could not be loaded, either because the server or network failed or because the format is not supported.

Reply
Jesse-Rushlow Avatar
Jesse-Rushlow Avatar Jesse-Rushlow | SFCASTS | Ruslan | posted 9 months ago

Howdy! Are you seeing that error when attempting to play the video?

Reply
Ruslan Avatar

Yes.
But, I see it works now.

Reply
Jesse-Rushlow Avatar
Jesse-Rushlow Avatar Jesse-Rushlow | SFCASTS | Ruslan | posted 9 months ago

Awesome! It sounds like there was a slight network hiccup. We hope you're enjoying the series and learning a thing or two along the way.

Reply
Ruslan Avatar

It's very helpfull series I hope we will see about others tests too.
Thank you.

1 Reply
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