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 rule ExpectedException do JUnit 4 evoluíram para o método assertThrows<T> no JUnit Jupiter 6.
  • Seremos capazes de capturar e escrever asserções fluentes sobre a exceção lançada utilizando o AssertJ (catchThrowable, catchThrowableOfType e assertThatThrownBy).
  • 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 em Kotlin é o seguinte:

1
2
3
4
5
6
7
8
package com.testwithspring.starter.assertions

class TaskFinder {

    fun findById(id: Long): Task {
        throw NotFoundException(id)
    }
}

A classe NotFoundException estende RuntimeException e possui um construtor que monta sua mensagem de erro formatada e armazena o ID da entidade solicitada na propriedade id.

O código-fonte da classe NotFoundException é o seguinte:

1
2
3
4
package com.testwithspring.starter.assertions

class NotFoundException(val id: Long) :
    RuntimeException("No entity found with id: $id")

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:

1
2
3
4
5
// Legado JUnit 4 (NÃO existe mais no JUnit Jupiter 6)
@org.junit.Test(expected = NotFoundException::class)
fun findById_ShouldThrowException_JUnit4() {
    finder.findById(ID)
}

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:

  1. Adicionamos um novo método de teste à nossa classe de teste.
  2. Invocamos assertThrows<NotFoundException> { ... } passando a chamada finder.findById(ID) dentro do bloco lambda.

O código-fonte da nossa classe de teste no JUnit 6 fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.testwithspring.starter.assertions

import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows

class TaskFinderTest {

    companion object {
        private const val ID = 1L
    }

    private lateinit var finder: TaskFinder

    @BeforeEach
    fun createSystemUnderTest() {
        finder = TaskFinder()
    }

    @Test
    fun `findById deve lancar NotFoundException`() {
        assertThrows<NotFoundException> {
            finder.findById(ID)
        }
    }
}

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():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Contexto histórico: JUnit 4 ExpectedException Rule
class LegacyJUnit4TaskFinderTest {

    @get:org.junit.Rule
    val thrown: org.junit.rules.ExpectedException =
        org.junit.rules.ExpectedException.none()

    private lateinit var finder: TaskFinder

    @org.junit.Before
    fun createSystemUnderTest() {
        finder = TaskFinder()
    }

    @org.junit.Test
    fun findById_ShouldThrowException() {
        thrown.expect(NotFoundException::class.java)
        finder.findById(1L)
    }

    @org.junit.Test
    fun findById_ShouldThrowExceptionThatHasCorrectId() {
        thrown.expect(org.hamcrest.Matchers.hasProperty("id", org.hamcrest.Matchers.`is`(1L)))
        finder.findById(1L)
    }
}

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 objeto Throwable lançado, ou retorna null caso 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:

  1. Capturamos o objeto Throwable lançado pelo método findById() usando catchThrowable.
  2. 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.testwithspring.starter.assertions

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class TaskFinderTest {

    companion object {
        private const val ID = 1L
    }

    private lateinit var finder: TaskFinder

    @BeforeEach
    fun createSystemUnderTest() {
        finder = TaskFinder()
    }

    @Test
    fun `findById deve lancar excecao NotFoundException`() {
        val thrown: Throwable? = catchThrowable { finder.findById(ID) }
        assertThat(thrown).isExactlyInstanceOf(NotFoundException::class.java)
    }
}

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:

  1. Capturamos o objeto Throwable lançado pelo método findById().
  2. Fazemos o cast seguro do objeto Throwable para NotFoundException (ou usamos catchThrowableOfType) e atribuímos a uma variável local.
  3. Escrevemos uma asserção do AssertJ que verifica se o objeto NotFoundException contém o id esperado.

O código-fonte do nosso método de teste fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.testwithspring.starter.assertions

import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.catchThrowable
import org.assertj.core.api.Assertions.catchThrowableOfType
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class TaskFinderTest {

    companion object {
        private const val ID = 1L
    }

    private lateinit var finder: TaskFinder

    @BeforeEach
    fun createSystemUnderTest() {
        finder = TaskFinder()
    }

    @Test
    fun `findById deve lancar excecao que possui o id correto`() {
        val thrown: Throwable? = catchThrowable { finder.findById(ID) }
        val notFound = thrown as NotFoundException

        assertThat(notFound.id).isEqualByComparingTo(ID)
    }

    @Test
    fun `findById deve lancar excecao com id e mensagem corretos usando catchThrowableOfType`() {
        val notFound: NotFoundException = catchThrowableOfType(
            { finder.findById(ID) },
            NotFoundException::class.java
        )

        assertThat(notFound.id).isEqualByComparingTo(ID)
        assertThat(notFound.message).isEqualTo("No entity found with id: 1")
    }
}

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:

  1. 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 inspecionar ex.id na linha seguinte!
  2. A antiga ExpectedException rule (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.
  3. 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 ou catchThrowable do 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 / catchThrowableOfType do AssertJ (ou capturar o retorno de assertThrows<T> e validá-lo com assertThat(...)).

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 com assertThat(...).
  • 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.