Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Testing Exceptional Exceptions

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

Do you remember when we were seeing this exception because our app didn't understand Maverick's "hungry" status? Welp, we've fixed that, but we still need to take care of one minor detail. Next time GenLab throws us a curve ball, like setting "Status: Antsy" on a dino, GithubService should throw a clear exception that mentions the label.

Where can we throw an exception?

To do that, we're going to take a break from TDD for just a moment. In getDinoStatusFromLabels(), if a label has the "Status:" prefix, we chop that off, set what's left on $status, and pass that into tryFrom() so we can return a HealthStatus. I think this would be a good spot to throw an exception if tryFrom() returns null.

Cut HealthStatus::tryFrom($status) from the return and right above add $health = and paste. Then if (null === $health) we'll throw new \RuntimeException() with the message, sprintf('%s is an unknown status label!') passing in $status. Below return $health.

But, if the issue doesn't have a status label, we still need to return a HealthStatus. So above, replace $status with $health = HealthStatus::HEALTHY, because unless GenLab adds a "Status: Sick" label, all of our dinos are healthy:

... lines 1 - 8
class GithubService
{
... lines 11 - 37
private function getDinoStatusFromLabels(array $labels): HealthStatus
{
$health = null;
foreach ($labels as $label) {
... lines 43 - 49
// Remove the "Status:" and whitespace from the label
$status = trim(substr($label, strlen('Status:')));
$health = HealthStatus::tryFrom($status);
// Determine if we know about the label - throw an exception if we don't
if (null === $health) {
throw new \RuntimeException(sprintf('%s is an unknown status label!', $label));
}
}
return $health ?? HealthStatus::HEALTHY;
}
}

Is the exception thrown?

Now, normally we write tests for return values. But you can also write tests to verify that the correct exception is thrown. So let's do that in GithubServiceTest. Hmm... This first test has a lot of the logic we'll need. Copy that and paste it at the bottom. Change the name to testExceptionThrownWithUnknownLabel and remove the arguments. Inside, take out the assertion leaving just the call to $service->getHealthReport(). And instead of $dinoName, pass in Maverick. For $mockResponse, remove Daisy from willReturn() and change Mavericks label from Healthy to Drowsy:

... lines 1 - 11
class GithubServiceTest extends TestCase
{
... lines 14 - 61
public function testExceptionThrownWithUnknownLabel(): void
{
... lines 64 - 67
$mockResponse
->method('toArray')
->willReturn([
[
'title' => 'Maverick',
'labels' => [['name' => 'Status: Drowsy']],
],
])
;
... lines 77 - 86
$service->getHealthReport('Maverick');
}
}

Alrighty, lets give this a shot:

./vendor/bin/phpunit

And... Ouch! GithubServiceTest failed because of a:

RuntimeException: Drowsy is an unknown status label!

This is actually good news. It means GithubService is doing exactly what we want it to do. But, how do we make this test pass?

Right before we call getHealthReport(), add $this->expectException() passing in \RuntimeException::class:

... lines 1 - 11
class GithubServiceTest extends TestCase
{
... lines 14 - 61
public function testExceptionThrownWithUnknownLabel(): void
{
... lines 64 - 67
$mockResponse
->method('toArray')
->willReturn([
[
'title' => 'Maverick',
'labels' => [['name' => 'Status: Drowsy']],
],
])
;
... lines 77 - 86
$this->expectException(\RuntimeException::class);
$service->getHealthReport('Maverick');
}
}

Try the tests again:

./vendor/bin/phpunit

Um... awesome sauce! We're green!

Prevent typo's in the exception message

But, hmm... If we manage to dork up our code on accident, a RuntimeException could be coming from someplace else. To make sure we're testing the correct exception, say $this->expectExceptionMessage('Drowsy is an unknown status label!'):

... lines 1 - 11
class GithubServiceTest extends TestCase
{
... lines 14 - 61
public function testExceptionThrownWithUnknownLabel(): void
{
... lines 64 - 67
$mockResponse
->method('toArray')
->willReturn([
[
'title' => 'Maverick',
'labels' => [['name' => 'Status: Drowsy']],
],
])
;
... lines 77 - 86
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Drowsy is an unknown status label!');
$service->getHealthReport('Maverick');
}
}

Then run our spell checker again:

./vendor/bin/phpunit

And... HA! We've added another assertion that is passing and we don't have any typo's in our message. WooHoo!

Test more than the exception message

Along with expectExceptionMessage(), PHPUnit has expectations for the exception code, object, and even has the ability to pass a regex to match the message. By the way, all of these expect methods are just like the assert methods. The big difference is that they must be called before the action you're testing rather than after. And just like assertions, if we change the expected message from Drowsy to Sleepy and run the test:

./vendor/bin/phpunit

Hmm... Yup! We'll see the test fail because Drowsy is not Sleepy. Let's change that back in the test... And there you have it! Dinotopia's gates are now open and Bob is much happier now that our app is updated in real-time with GenLab! To celebrate, let's make our lives a bit easier by using a touch of HttpClient magic to refactor our test.

Leave a comment!

4
Login or Register to join the conversation
yaroslavche Avatar
yaroslavche Avatar yaroslavche | posted 6 months ago

Since we're using PHP ^8.0, I think that also correct way to use null-coalesce operator throwing an exception:

return HealthStatus::tryFrom($status) ?? throw new \RuntimeException(sprintf('%s is an unknown status label!', $status));

In that case, we don't need to declare extra variables, but should think about line length and good readability.
Also, in case within two issues for same Dinosaur with different labels we can face exception, when shouldn't (if latest label is Healthy, but previous is unknown and not closed for some reasons).

In addition, in test class it's possible to instantiate an exception, and use expectExceptionObject, which can be useful in some cases (f.i., within data providers).

Reply

Hi yaroslavche!

That's a cool idea to use the null-coalesce to throw an exception - like it!

In addition, in test class it's possible to instantiate an exception, and use expectExceptionObject()

I'm actually not familiar with expectExceptionObject() - and I could find almost no info on it! It looks undocumented, though a comment on StackOverflow says that it works as of PHPUnit 9.5. So, super interesting!

Cheers!

Reply
yaroslavche Avatar

expectExceptionObject is just a sugar for expectException, expectExpceptionMessage and expectExceptionCode.

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Drowsy is an unknown status label!');
$this->expectExceptionCode(1);

the same as

$this->expectExceptionObject(new \RuntimeException('Drowsy is an unknown status label!', 1));
Reply

Beautiful - I like that a lot!

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