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 continue refactoring our test. In the test method, we create a MockResponse
, MockHttpClient
, and instantiate GitHubService
with a mock LoggerInterface
. We're doing the same thing in this test above. Didn't Ryan say to DRY out our code in another tutorial? Fine... I suppose we'll listen to him.
Start by adding three private
properties to our class: a LoggerInterface $mockLogger
, followed by MockHttpClient $mockHttpClient
and finally MockResponse $mockresponse
:
... lines 1 - 13 | |
class GithubServiceTest extends TestCase | |
{ | |
private LoggerInterface $mockLogger; | |
private MockHttpClient $mockHttpClient; | |
private MockResponse $mockResponse; | |
... lines 19 - 93 | |
} |
At the bottom of the test, create a private function createGithubService()
that requires array $responseData
then returns GithubService
. Inside, say $this->mockResponse = new MockResponse()
that json_encode()
's the $responseData
:
... lines 1 - 13 | |
class GithubServiceTest extends TestCase | |
{ | |
private LoggerInterface $mockLogger; | |
private MockHttpClient $mockHttpClient; | |
private MockResponse $mockResponse; | |
... lines 19 - 85 | |
private function createGithubService(array $responseData): GithubService | |
{ | |
$this->mockResponse = new MockResponse(json_encode($responseData)); | |
... lines 89 - 92 | |
} | |
} |
Since we'll be creating the MockResponse
after we instantiate the MockHttpClient
, which you'll see in a second, we need to pass our response to the client without using the client's constructor. To do that, we can say $this->mockHttpClient->setResponseFactory($this->mockResponse)
. Finally return a new GithubService()
with $this->mockHttpClient
and $this->mockLogger
.
... lines 1 - 13 | |
class GithubServiceTest extends TestCase | |
{ | |
private LoggerInterface $mockLogger; | |
private MockHttpClient $mockHttpClient; | |
private MockResponse $mockResponse; | |
... lines 19 - 85 | |
private function createGithubService(array $responseData): GithubService | |
{ | |
$this->mockResponse = new MockResponse(json_encode($responseData)); | |
$this->mockHttpClient->setResponseFactory($this->mockResponse); | |
return new GithubService($this->mockHttpClient, $this->mockLogger); | |
} | |
} |
We could use a constructor to instantiate our mocks and set them on those properties. But PHPUnit will only instantiate our test class once, no matter how many test methods it has. And we want to make sure we have fresh mock objects for each test run. How can we do that? At the top, add protected function setUp()
. Inside, say $this->mockLogger = $this->createMock(LoggerInterface::class)
then $this->mockHttpClient = new MockHttpClient()
.
... lines 1 - 13 | |
class GithubServiceTest extends TestCase | |
{ | |
private LoggerInterface $mockLogger; | |
private MockHttpClient $mockHttpClient; | |
private MockResponse $mockResponse; | |
protected function setUp(): void | |
{ | |
$this->mockLogger = $this->createMock(LoggerInterface::class); | |
$this->mockHttpClient = new MockHttpClient(); | |
} | |
... lines 25 - 88 | |
private function createGithubService(array $responseData): GithubService | |
{ | |
$this->mockResponse = new MockResponse(json_encode($responseData)); | |
$this->mockHttpClient->setResponseFactory($this->mockResponse); | |
return new GithubService($this->mockHttpClient, $this->mockLogger); | |
} | |
} |
Down in the test method, cut the response array, then say $service = $this->createGithubService()
and paste the array.
... lines 1 - 13 | |
class GithubServiceTest extends TestCase | |
{ | |
private LoggerInterface $mockLogger; | |
private MockHttpClient $mockHttpClient; | |
private MockResponse $mockResponse; | |
protected function setUp(): void | |
{ | |
$this->mockLogger = $this->createMock(LoggerInterface::class); | |
$this->mockHttpClient = new MockHttpClient(); | |
} | |
... lines 25 - 73 | |
public function testExceptionThrownWithUnknownLabel(): void | |
{ | |
$service = $this->createGithubService([ | |
[ | |
'title' => 'Maverick', | |
'labels' => [['name' => 'Status: Drowsy']], | |
], | |
]); | |
... lines 82 - 85 | |
$service->getHealthReport('Maverick'); | |
} | |
private function createGithubService(array $responseData): GithubService | |
{ | |
$this->mockResponse = new MockResponse(json_encode($responseData)); | |
$this->mockHttpClient->setResponseFactory($this->mockResponse); | |
return new GithubService($this->mockHttpClient, $this->mockLogger); | |
} | |
} |
Let's see how our tests are doing in the terminal...
./vendor/bin/phpunit
And... Ya! Everything is looking good!
The idea is pretty simple: if your test class has a method called setUp()
, PHPUnit will call it before each test method, which gives us fresh mocks at the start of every test. Need to do something after each test? Same thing: create a method called tearDown()
. This isn't as common... but you might do it if you want to clean up some filesystem changes that were made during the test. In our case, there's no need.
In addition to setUp()
and tearDown()
, PHPUnit also has a few other methods, like setUpBeforeClass()
and tearDownAfterClass()
. These are called once per class, and we'll get more into those as they become relevant in future tutorials. And if you were wondering, these methods are called "Fixture Methods" because they help setup any "fixtures" to get your environment into a known state for your test.
Anyhow, let's get back to refactoring. For the first test in this class, cut out the response array, select all of this "dead code", add $service = $this->createGithubService()
then paste in the array. We can remove the $service
variable below:
... lines 1 - 11 | |
class GithubServiceTest extends TestCase | |
{ | |
... lines 14 - 26 | |
public function testGetHealthReportReturnsCorrectHealthStatusForDino(HealthStatus $expectedStatus, string $dinoName): void | |
{ | |
$service = $this->createGithubService([ | |
[ | |
'title' => 'Daisy', | |
'labels' => [['name' => 'Status: Sick']], | |
], | |
[ | |
'title' => 'Maverick', | |
'labels' => [['name' => 'Status: Healthy']], | |
], | |
]); | |
... lines 39 - 43 | |
} | |
... lines 45 - 81 | |
} |
But now we need to figure out how to keep these expectations that we were using on the old $mockHttpClient
. Being able to test that we only call GitHub once with the GET
HTTP Method and that we're using the right URL, is pretty valuable.
Fortunately, those mock classes have special code just for this. Below, assertSame()
that 1
is identical to $this->mockHttpClient->getRequestCount()
then assertSame()
that GET
is identical to $this->mockResponse->getRequestMethod()
. Finally, copy and paste the URL into assertSame()
and call getRequestUrl()
on mockResponse
. Remove the old $mockHttpClient
... and the use
statements that we're no longer using up top.
... lines 1 - 11 | |
class GithubServiceTest extends TestCase | |
{ | |
... lines 14 - 26 | |
public function testGetHealthReportReturnsCorrectHealthStatusForDino(HealthStatus $expectedStatus, string $dinoName): void | |
{ | |
$service = $this->createGithubService([ | |
[ | |
'title' => 'Daisy', | |
'labels' => [['name' => 'Status: Sick']], | |
], | |
[ | |
'title' => 'Maverick', | |
'labels' => [['name' => 'Status: Healthy']], | |
], | |
]); | |
self::assertSame($expectedStatus, $service->getHealthReport($dinoName)); | |
self::assertSame(1, $this->mockHttpClient->getRequestsCount()); | |
self::assertSame('GET', $this->mockResponse->getRequestMethod()); | |
self::assertSame('https://api.github.com/repos/SymfonyCasts/dino-park/issues', $this->mockResponse->getRequestUrl()); | |
} | |
... lines 45 - 81 | |
} |
Alrighty, time to check the fences...
./vendor/bin/phpunit
And... Wow! Everything is still green!
Welp, there you have it... We've helped Bob improve Dinotopia by adding a few small features to the app. But more importantly, we're able to test that those features are working as we intended. Is there more work to be done? Absolutely! We're going to take our app to the next level by adding a persistence layer to store dinos in the database and learn how to write tests for that too. These tests, where you use real database connections or make real API calls, instead of mocking, are sometimes called integration tests. That's the topic of the next tutorial in this series.
I hope you enjoyed your time here at the park - and thanks for keeping your arms and legs inside the vehicle at all times. If you have any questions, suggestions, or want to ride with Big Eaty in the Jeep - just leave us a comment. Alright, see you in the next episode!
Howdy!
I'm glad that you not only enjoyed the new series, but it was also helpful to you! That's music to our ears...
why can dino length be zero or negative? They deserve to have an assert for that! =)
This is actually a very good question! If PHP had native support for unsigned integers
we could prevent a dino's length being "-100" or something silly like that. But, in all honesty, this isn't a use case I'd be too worried about. There's a balance between writing tests for every possible scenario, keeping your tests maintainable, and being practical. If the dino's size was used to determine if they could be let loose in the petting zoo, we'd probably would have a test to keep Big Eaty out of there... :)
Thank you for the feedback. We appreciate it!
I agree about balance, but let me argue a little =) Since Dinosaur entity have only one place to change length (the constructor), then we can add a condition there, which would check length and otherwise throws an exception. Obviously, dino length can't be negative or zero on instantiation. Then in \App\Tests\Unit\Entity\DinosuarTest::testDinosaurHasCorrectSizeDescriptionFromLength
we can expect that exception, when data provider yields 0 or negative number. In that case, we can be sure, that each dino has a valid size. But, also it requires several changes in tests (instantiation with length or set non-negative default size), since right now default size is 0.
That is definitely one way to do it, and probably how I would tackle it if it were a big enough concern of mine. But, again it's all about prioritizing area of concerns. If the app is only instantiating Dinosaur
's without using unsafe input, say from a form, then ya - I'm not going to bother... But, if we did something like that in episode 3, who knows what might happen...
Great and good to follow tutorial ... i hope bob is happy.
Cant wait for part II
Awesome! I'm glad you enjoyed it. Episode 2 is in the works now and we can't wait to release it. Stay Tuned...
// 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
}
}
I really enjoyed this course, many thanks. I found a lot of interesting practices that I will definitely use in the future. Looking forward to the next parts.
I have only one question (just kidding): why can dino length be zero or negative? They deserve to have an assert for that! =)