Escrevendo Asserções para Exceções
Ao concluirmos esta aula, nós:
- Seremos capazes de escrever asserções para o tipo da exceção lançada.
- Compreenderemos como o atributo
@Test(expected = ...)e a ruleExpectedExceptiondo JUnit 4 evoluíram para o métodoassertThrows<T>no JUnit Jupiter 6. - Seremos capazes de capturar e escrever asserções fluentes sobre a exceção lançada utilizando o AssertJ (
catchThrowable,catchThrowableOfTypeeassertThatThrownBy). - Saberemos selecionar a melhor ferramenta para cada situação.
Introdução ao Sistema Sob Teste (SUT)
Durante esta aula, escreveremos testes unitários para a classe TaskFinder. Essa classe possui um único método chamado findById(), que recebe o ID da tarefa solicitada como parâmetro e lança uma exceção NotFoundException.
O código-fonte da classe TaskFinder e de NotFoundException no nosso projeto de testes é o seguinte:
|
|
A seguir, escreveremos diferentes asserções para a exceção lançada pelo método findById(). Começaremos analisando como isso era feito historicamente no JUnit 4 e como fazemos hoje no JUnit Jupiter 6 e no AssertJ.
1. Verificando o Tipo da Exceção: De @Test(expected) para assertThrows
No JUnit 4 clássico, se quiséssemos verificar apenas que um teste passava quando uma exceção específica era lançada, configurávamos o atributo expected da anotação @Test:
|
|
Embora parecesse simples, o atributo expected da anotação @Test tinha limitações severas (não permitia inspecionar a mensagem ou campos da exceção, nem verificar em qual linha exata do teste a exceção ocorreu).
No JUnit Jupiter 6, o atributo expected foi removido da anotação @Test e substituído pela função nativa assertThrows<T>() (do pacote org.junit.jupiter.api.assertThrows), que recebe um bloco lambda com a chamada que deve lançar a exceção:
- Adicionamos um novo método de teste à nossa classe de teste.
- Invocamos
assertThrows<NotFoundException> { ... }passando a chamadafinder.findById(ID)dentro do bloco lambda.
O código-fonte da nossa classe de teste no JUnit 6 fica assim:
|
|
Além de verificar o tipo da exceção, assertThrows<NotFoundException> retorna a instância da exceção capturada, caso precisemos fazer verificações adicionais!
Mas antes de vermos o AssertJ, vamos entender como o JUnit 4 lidava com verificações de propriedades da exceção usando a antiga rule ExpectedException.
2. Contexto Histórico: A Rule ExpectedException do JUnit 4
No JUnit 4, antes do Java 8 e do JUnit Jupiter, a rule ExpectedException dava a possibilidade de capturar a exceção lançada pelo Sistema Sob Teste e escrever asserções sobre ela usando matchers do Hamcrest.
Para habilitar a ExpectedException rule no JUnit 4, era necessário declarar um campo público anotado com @Rule inicializado com ExpectedException.none():
|
|
Perceba por que a ExpectedException rule foi depreciada e removida no JUnit moderno:
- Ela exigia código de configuração extra na classe de teste (
@Rule ExpectedException). - A sintaxe invertia a ordem lógica do teste (Arrange-Act-Assert): você era obrigado a declarar a expectativa (
thrown.expect(...)) antes de invocar o método testado (finder.findById(ID)). - Você não podia adicionar nenhuma asserção nem verificar interações com mocks após a linha que lançava a exceção, pois a execução do método abortava naquela linha.
Vamos avançar e descobrir como o AssertJ resolve todos esses problemas de forma elegante e fluente!
3. Escrevendo Asserções para Exceções com AssertJ
Com o AssertJ em Kotlin, podemos capturar a exceção lançada invocando o método estático catchThrowable() (ou a variante tipada catchThrowableOfType() / assertThatThrownBy()) da classe org.assertj.core.api.Assertions.
Ao utilizarmos catchThrowable(), devemos saber duas coisas:
- Passamos uma expressão lambda que deve lançar a exceção como parâmetro do método.
- O método
catchThrowable()captura e retorna o objetoThrowablelançado, ou retornanullcaso nenhuma exceção tenha sido lançada.
Depois de capturarmos a exceção lançada, podemos escrever asserções fluentes sobre ela usando toda a API do AssertJ. Vamos escrever dois métodos de teste que verificam diferentes aspectos da exceção lançada pelo método findById() da classe TaskFinder.
Primeiro Teste: Verificar o Tipo Exato da Exceção Lançada
Se quisermos verificar que o método findById() lança uma NotFoundException, implementamos nosso método de teste seguindo estes dois passos:
- Capturamos o objeto
Throwablelançado pelo métodofindById()usandocatchThrowable. - Escrevemos uma asserção que verifica se o tipo exato da exceção lançada é
NotFoundException.
O código-fonte do nosso método de teste fica assim:
|
|
Segundo Teste: Verificar que a NotFoundException Contém o ID Correto
Se quisermos verificar que o objeto NotFoundException lançado contém o id esperado (99L ou 1L), implementamos nosso método de teste seguindo estes três passos:
- Capturamos o objeto
Throwablelançado pelo métodofindById(). - Fazemos o cast seguro do objeto
ThrowableparaNotFoundException(ou usamoscatchThrowableOfType) e atribuímos a uma variável local. - Escrevemos uma asserção do AssertJ que verifica se o objeto
NotFoundExceptioncontém oidesperado.
O código-fonte do nosso método de teste fica assim:
|
|
Código Completo da Suíte de Asserções para Exceções
Abaixo apresentamos a classe completa ExceptionAssertionsTest em nosso repositório real, integrando tanto as asserções nativas com assertThrows do JUnit Jupiter quanto as abordagens fluentes com catchThrowable, catchThrowableOfType e assertThatThrownBy do AssertJ:
|
|
Executando Nossos Testes de Exceção no Gradle
Ao executarmos a suíte ExceptionAssertionsTest através do Gradle (./gradlew test --tests ExceptionAssertionsTest -i), inspecionamos o log de validação com sucesso:
Initialized native services in: /home/wc/.gradle/native
Initialized jansi services in: /home/wc/.gradle/native
The client will now receive all logging from the daemon (pid: 16102, address: [fe80::1%1:54330]).
Gradle Daemon started in 1 s 042 ms
Starting Build
Settings evaluated using settings file '/home/wc/test-with-spring/code/01-starter-junit6/settings.gradle.kts'.
Projects loaded. Root project using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Included projects: [root project 'starter-junit6']
> Configure project :
Evaluating root project 'starter-junit6' using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Using Kotlin Gradle Plugin 2.4.21
Using Java Toolchain: Temurin 21.0.6 (/home/wc/.sdkman/candidates/java/21.0.6-tem)
Spring Boot 4.1.2 plugin applied.
All projects evaluated.
Selected primary task 'test' from project :
Tasks to be executed: [task ':compileKotlin', task ':compileJava', task ':processResources', task ':classes', task ':compileTestKotlin', task ':compileTestJava', task ':processTestResources', task ':testClasses', task ':test']
Tasks that were skipped: [task ':compileJava', task ':compileTestJava']
> Task :compileKotlin UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestKotlin
Compiling with Kotlin compiler version 2.4.21 (JVM target 21)
Compiling Kotlin files from [/home/wc/test-with-spring/code/01-starter-junit6/src/test/kotlin]
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
Starting process 'Gradle Test Executor 1'. Working directory: /home/wc/test-with-spring/code/01-starter-junit6 Command: /home/wc/.sdkman/candidates/java/21.0.6-tem/bin/java -ea -cp ...
Successfully started process 'Gradle Test Executor 1'
com.testwithspring.starter.assertions.ExceptionAssertionsTest > Verificando exceções com JUnit Jupiter assertThrows > findById deve lancar NotFoundException usando assertThrows PASSED
com.testwithspring.starter.assertions.ExceptionAssertionsTest > Verificando exceções com AssertJ > findById deve lancar excecao NotFoundException usando catchThrowable PASSED
com.testwithspring.starter.assertions.ExceptionAssertionsTest > Verificando exceções com AssertJ > findById deve lancar excecao com id e mensagem corretos usando catchThrowableOfType PASSED
com.testwithspring.starter.assertions.ExceptionAssertionsTest > Verificando exceções com AssertJ > findById deve verificar tipo e mensagem de excecao usando assertThatThrownBy PASSED
ExceptionAssertionsTest > 4 tests completed, 0 failed, 0 skipped
Finished generating test XML results (0.008 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/test-results/test
Generating HTML test report...
Finished generating test html results (0.015 secs) into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test
BUILD SUCCESSFUL in 1s
5 actionable tasks: 2 executed, 3 up-to-date
Qual Técnica Devemos Utilizar?
Ao decidirmos qual técnica utilizar em nossos projetos, devemos comparar nossos requisitos com os pontos fortes e fracos de cada abordagem:
- Verificar apenas o tipo da exceção (
assertThrows<T>do JUnit Jupiter — substituto moderno do@Test(expected)):- É uma maneira extremamente simples, limpa e idiomática em Kotlin para especificar o tipo da exceção esperada.
- Além disso, no JUnit Jupiter 6,
val ex = assertThrows<NotFoundException> { ... }também retorna a exceção capturada, permitindo inspecionarex.idna linha seguinte!
- A antiga
ExpectedExceptionrule (JUnit 4):- Exigia código de setup extra, invertia a ordem natural Act -> Assert e impedia asserções posteriores ou verificações de mocks. Hoje está obsoleta e não deve mais ser usada.
- Capturar a exceção com AssertJ (
catchThrowable/catchThrowableOfType/assertThatThrownBy):- Captura a exceção de forma explícita na fase When (Act) do teste.
- Permite adicionar asserções adicionais no mesmo método de teste, verificar interações entre o Sistema Sob Teste e nossos objetos mock (MockK) após o lançamento da exceção, e inspecionar a exceção com toda a API fluente do AssertJ.
Portanto, podemos seguir estas diretrizes práticas:
- Se precisarmos apenas verificar que o tipo da exceção lançada está correto,
assertThrows<NotFoundException> { finder.findById(ID) }do JUnit 6 oucatchThrowabledo AssertJ são excelentes escolhas. - Se precisarmos inspecionar propriedades customizadas da exceção, encadear validações fluentes ou verificar interações com mocks após a falha do método, devemos capturar a exceção com
catchThrowable/catchThrowableOfTypedo AssertJ (ou capturar o retorno deassertThrows<T>e validá-lo comassertThat(...)).
Resumo
Nesta aula, aprendemos três lições fundamentais:
- Para verificar de forma simples que uma chamada lança um tipo específico de exceção, usamos
assertThrows<T> { ... }no JUnit Jupiter 6 (que substituiu o antigo@Test(expected = ...)do JUnit 4). - Para escrever asserções ricas sobre propriedades, causas ou mensagens da exceção lançada, utilizamos o AssertJ (
catchThrowable,catchThrowableOfType) combinado comassertThat(...). - Como o AssertJ (assim como
assertThrows) captura a exceção em vez de abortar o método de teste, podemos adicionar asserções extras ou verificar interações entre o Sistema Sob Teste e nossos mocks logo após a execução do método que lançou a exceção.