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 SubscribeOur 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!
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
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!
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.
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 | |
{ | |
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 | |
{ | |
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 beHealthStatus
,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.
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!
Since the client is mocked, I don't understand how it validates that it uses GET with the correct URL. Can you please explain?
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!
Something does not work: - The media could not be loaded, either because the server or network failed or because the format is not supported.
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.
// 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
}
}
Within the context of this test, aren't we testing implementation instead of behavior if we check that only one request is made?