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 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.
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; | |
} | |
} |
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!
Pero... Si conseguimos estropear nuestro código por accidente, un RuntimeException
podrí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!
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.
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!
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));
// 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
}
}
Since we're using PHP ^8.0, I think that also correct way to use null-coalesce operator throwing an exception:
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).