Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Probando Excepciones Excepcionales

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

¿Recuerdas cuando veíamos esta excepción porque nuestra aplicación no entendía el estado de "hambre" de Maverick? Bueno, ya lo hemos arreglado, pero todavía tenemos que ocuparnos de un pequeño detalle. La próxima vez que GenLab nos lance una bola curva, como poner "Estado: Antsy" en un dino, GithubService debería lanzar una excepción clara que mencione la etiqueta.

¿Dónde podemos lanzar una excepción?

Para ello, vamos a hacer una pausa en TDD por un momento. EngetDinoStatusFromLabels(), si una etiqueta tiene el prefijo "Estado:", lo cortamos, ponemos lo que queda en $status, y lo pasamos a tryFrom() para poder devolver un HealthStatus. Creo que éste sería un buen punto para lanzar una excepción si tryFrom() devuelve null.

Corta HealthStatus::tryFrom($status) del retorno y justo encima añade $health = y pega. Entonces if (null === $health) lo haremos throw new \RuntimeException() con el mensaje, sprintf('%s is an unknown status label!') pasando por $status. Abajo devuelve $health.

Pero, si el asunto no tiene una etiqueta de estado, todavía tenemos que devolver unHealthStatus. Así que arriba, sustituye $status por $health = HealthStatus::HEALTHY, porque a menos que GenLab añada una etiqueta de "Estado: Enfermo", todos nuestros dinos están sanos:

... 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;
}
}

¿Se lanza la excepción?

Ahora bien, normalmente escribimos pruebas para los valores de retorno. Pero también puedes escribir pruebas para verificar que se lanza la excepción correcta. Así que hagamos eso en GithubServiceTest. Hmm... Esta primera prueba tiene gran parte de la lógica que necesitaremos. Cópiala y pégala en la parte inferior. Cambia el nombre a testExceptionThrownWithUnknownLabel y elimina los argumentos. Dentro, quita la aserción dejando sólo la llamada a$service->getHealthReport(). Y en lugar de $dinoName, pasa a Maverick. Para $mockResponse, quita la margarita de willReturn() y cambia la etiqueta Mavericks de Healthy a 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');
}
}

Muy bien, vamos a intentarlo:

./vendor/bin/phpunit

Y... Ouch! GithubServiceTest falló debido a una:

Excepción de tiempo de ejecución: ¡Drowsy es una etiqueta de estado desconocida!

En realidad, esto es una buena noticia. Significa que GithubService está haciendo exactamente lo que queremos que haga. Pero, ¿cómo hacemos que esta prueba pase?

Justo antes de llamar a getHealthReport(), añade $this->expectException() pasando por \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');
}
}

Vuelve a probar las pruebas:

./vendor/bin/phpunit

¡Impresionante salsa! ¡Estamos en verde!

Evita las erratas en el mensaje de excepción

Pero... Si conseguimos estropear nuestro código por accidente, un RuntimeExceptionpodría venir de otro sitio. Para asegurarnos de que estamos comprobando la excepción correcta, di $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');
}
}

Luego vuelve a ejecutar nuestro corrector ortográfico:

./vendor/bin/phpunit

Y... ¡AH! Hemos añadido otra aserción que está pasando y no tenemos ninguna errata en nuestro mensaje. ¡Guau!

Prueba algo más que el mensaje de excepción

Junto con expectExceptionMessage(), PHPUnit tiene expectativas para el código de la excepción, el objeto, e incluso tiene la capacidad de pasar una regex para que coincida con el mensaje. Por cierto, todos estos métodos de expect son iguales a los de assert. La gran diferencia es que deben llamarse antes de la acción que estás probando y no después. Y al igual que las aserciones, si cambiamos el mensaje esperado de Drowsy a Sleepy y ejecutamos la prueba:

./vendor/bin/phpunit

Hmm... ¡Sí! Veremos que la prueba falla porque Drowsy no es Sleepy. Volvamos a cambiarlo en la prueba... ¡Y ahí lo tienes! ¡Las puertas de Dinotopia ya están abiertas y Bob es mucho más feliz ahora que nuestra aplicación se actualiza en tiempo real con GenLab! Para celebrarlo, hagamos nuestra vida un poco más fácil utilizando un toque de magia HttpClient para refactorizar nuestro test.

Leave a comment!

4
Login or Register to join the conversation
yaroslavche Avatar
yaroslavche Avatar yaroslavche | posted hace 6 meses

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