Criando Soft Assertions com AssertJ (assertSoftly)

Ao concluirmos esta aula, nós:

  • Compreenderemos quando faz sentido escrever múltiplas asserções dentro do mesmo método de teste.
  • Saberemos como agrupar múltiplas asserções em um único método de teste utilizando as Soft Assertions do AssertJ (SoftAssertions e SoftAssertions.assertSoftly).

O Que É uma Soft Assertion?

Muitos desenvolvedores acreditam que cada método de teste deve conter apenas uma única asserção. A razão para isso é que, se um método de teste possuir múltiplas asserções tradicionais (hard assertions) e uma delas falhar, não saberemos o resultado das asserções que deveriam ser executadas após a asserção que falhou — pois a primeira falha lança imediatamente um AssertionError e aborta o método.

Essa é uma boa regra geral, mas segui-la cegamente pode causar outro problema sério. Suponha que queiramos escrever testes para um método que transforma um objeto em outro objeto diferente.

Se seguirmos a regra de “uma asserção por teste” à risca, teremos que escrever um método de teste separado para cada propriedade do objeto retornado. Por exemplo, se precisarmos escrever asserções para a classe TaskDTO, o código-fonte da nossa classe de teste ficaria 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
package com.testwithspring.starter.assertions

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

@DisplayName("Escrevendo asserções para objetos TaskDTO")
class TaskDTOTest {

    @Nested
    @DisplayName("Quando convertemos uma Task em um objeto TaskDTO")
    inner class WhenWeConvertTaskIntoTaskDTO {

        private lateinit var dto: TaskDTO

        @BeforeEach
        fun convertTaskIntoTaskDTO() {
            // Cria a Task, converte para TaskDTO e atribui à propriedade dto
        }

        @Test
        @DisplayName("Deve definir o ID do colaborador responsável")
        fun shouldSetIdOfAssignee() {
            assertThat(dto.assigneeId).isEqualTo(99L)
        }

        @Test
        @DisplayName("Deve definir o ID do criador")
        fun shouldSetIdOfCreator() {
            assertThat(dto.creatorId).isEqualTo(1L)
        }

        @Test
        @DisplayName("Deve definir a descrição da tarefa")
        fun shouldSetDescriptionOfTask() {
            assertThat(dto.description).isEqualTo("This lesson talks about soft assertions")
        }

        @Test
        @DisplayName("Deve definir o ID da tarefa")
        fun shouldSetIdOfTask() {
            assertThat(dto.id).isEqualTo(1L)
        }

        @Test
        @DisplayName("Deve definir o ID do modificador")
        fun shouldSetIdOfModifier() {
            assertThat(dto.modifierId).isEqualTo(1L)
        }

        @Test
        @DisplayName("Deve definir a resolução da tarefa")
        fun shouldSetResolutionOfTask() {
            assertThat(dto.resolution).isEqualTo(TaskResolution.DONE)
        }

        @Test
        @DisplayName("Deve definir o status da tarefa")
        fun shouldSetStatusOfTask() {
            assertThat(dto.status).isEqualTo(TaskStatus.CLOSED)
        }

        @Test
        @DisplayName("Deve definir as tags da tarefa")
        fun shouldSetTagsOfTask() {
            // Verifica se a lista contém uma única TagDTO correta
        }

        @Test
        @DisplayName("Deve definir o título da tarefa")
        fun shouldSetTitleOfTask() {
            assertThat(dto.title).isEqualTo("Write example test")
        }
    }
}

Há um problema evidente com essa classe de teste:

Ela é difícil de ler devido à enorme quantidade de código repetitivo (boilerplate) necessário para declarar nove métodos de teste separados para verificar o resultado de uma única operação lógica.

Em outras palavras, se precisarmos verificar o estado de um objeto que contém múltiplas propriedades independentes, é muito melhor colocar todas as asserções dentro de um único método de teste. Contudo, se fizermos isso usando asserções normais (Assertions.assertThat()), voltaremos ao problema original: se a primeira asserção falhar, o teste será interrompido e não saberemos se as demais propriedades estavam corretas ou incorretas.

Felizmente para nós, podemos resolver esse problema utilizando Soft Assertions. Quando usamos soft assertions, o AssertJ coleta todas as falhas de asserção que ocorrem dentro do método de teste sem interromper a execução imediatamente e relata todas as falhas juntas ao final do bloco.

Vamos descobrir como podemos escrever soft assertions com o AssertJ!


Escrevendo Soft Assertions com AssertJ

Ao escrevermos soft assertions com o AssertJ, utilizamos a classe org.assertj.core.api.SoftAssertions. Em vez de usar o método estático Assertions.assertThat(), invocamos os métodos assertThat() de uma instância de SoftAssertions para especificar nossas expectativas.

No AssertJ moderno com Kotlin, existem duas maneiras principais de utilizar SoftAssertions:

  1. Abordagem com instância explícita e assertAll(): Criamos uma instância de SoftAssertions, registramos as asserções e chamamos softAssertions.assertAll() no final.
  2. Abordagem funcional com SoftAssertions.assertSoftly { ... } (Recomendada em Kotlin): Passamos um bloco lambda para assertSoftly, e o próprio AssertJ invoca assertAll() automaticamente ao final do bloco (sem o risco de esquecermos a chamada final).

Vamos primeiro ver passo a passo como funciona a abordagem explícita para entendermos o mecanismo interno, e em seguida a forma idiomática em Kotlin com assertSoftly.

Passo a Passo com SoftAssertions e assertAll()

Para escrever um método de teste que verifica se as informações de um objeto Person estão corretas usando uma instância de SoftAssertions, seguimos estes passos:

  1. Criamos um novo objeto Person.
  2. Criamos uma nova instância de SoftAssertions.
  3. Escrevemos as asserções que verificam se o primeiro nome (firstName) e o sobrenome (lastName) estão corretos, utilizando os métodos da instância SoftAssertions.
  4. Invocamos o método assertAll() da classe SoftAssertions para instruir o AssertJ a avaliar todas as asserções coletadas.

Vamos implementar esse teste na prática:

Passo 1: Criar o objeto Person

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private const val FIRST_NAME = "Jane"
private const val LAST_NAME = "Doe"

@Test
fun shouldCreatePersonWithCorrectFirstNameAndLastName() {
    val person = Person(
        firstName = FIRST_NAME,
        lastName = LAST_NAME
    )
}

Passo 2: Criar uma instância de SoftAssertions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private const val FIRST_NAME = "Jane"
private const val LAST_NAME = "Doe"

@Test
fun shouldCreatePersonWithCorrectFirstNameAndLastName() {
    val person = Person(
        firstName = FIRST_NAME,
        lastName = LAST_NAME
    )

    val softAssertions = SoftAssertions()
}

Passo 3: Especificar as asserções para firstName e lastName

 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
private const val FIRST_NAME = "Jane"
private const val LAST_NAME = "Doe"

@Test
fun shouldCreatePersonWithCorrectFirstNameAndLastName() {
    val person = Person(
        firstName = FIRST_NAME,
        lastName = LAST_NAME
    )

    val softAssertions = SoftAssertions()
    softAssertions.assertThat(person.firstName)
        .overridingErrorMessage(
            "Expected the firstName to be: %s but it was: %s",
            FIRST_NAME,
            person.firstName
        )
        .isEqualTo(FIRST_NAME)

    softAssertions.assertThat(person.lastName)
        .overridingErrorMessage(
            "Expected the lastName to be: %s but it was: %s",
            LAST_NAME,
            person.lastName
        )
        .isEqualTo(LAST_NAME)
}

Passo 4: Invocar assertAll()

 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
private const val FIRST_NAME = "Jane"
private const val LAST_NAME = "Doe"

@Test
fun shouldCreatePersonWithCorrectFirstNameAndLastName() {
    val person = Person(
        firstName = FIRST_NAME,
        lastName = LAST_NAME
    )

    val softAssertions = SoftAssertions()
    softAssertions.assertThat(person.firstName)
        .overridingErrorMessage(
            "Expected the firstName to be: %s but it was: %s",
            FIRST_NAME,
            person.firstName
        )
        .isEqualTo(FIRST_NAME)

    softAssertions.assertThat(person.lastName)
        .overridingErrorMessage(
            "Expected the lastName to be: %s but it was: %s",
            LAST_NAME,
            person.lastName
        )
        .isEqualTo(LAST_NAME)

    softAssertions.assertAll()
}

[!WARNING] Atenção ao assertAll()! Quando você instancia SoftAssertions() manualmente, não pode esquecer de chamar softAssertions.assertAll() na última linha do método de teste! Se você esquecer essa chamada, nenhuma asserção será efetivamente verificada e o seu teste passará com falso positivo mesmo que todas as propriedades estejam erradas.


A Forma Idiomática em Kotlin: SoftAssertions.assertSoftly

Para eliminar completamente o risco de esquecer a chamada assertAll(), o AssertJ oferece o método estático SoftAssertions.assertSoftly. Em Kotlin, podemos combiná-lo perfeitamente com blocos lambda:

 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
private const val FIRST_NAME = "Jane"
private const val LAST_NAME = "Doe"

@Test
fun shouldCreatePersonWithCorrectFirstNameAndLastNameUsingAssertSoftly() {
    val person = Person(
        firstName = FIRST_NAME,
        lastName = LAST_NAME
    )

    SoftAssertions.assertSoftly { softly ->
        softly.assertThat(person.firstName)
            .overridingErrorMessage(
                "Expected the firstName to be: %s but it was: %s",
                FIRST_NAME,
                person.firstName
            )
            .isEqualTo(FIRST_NAME)

        softly.assertThat(person.lastName)
            .overridingErrorMessage(
                "Expected the lastName to be: %s but it was: %s",
                LAST_NAME,
                person.lastName
            )
            .isEqualTo(LAST_NAME)
    }
}

Quando o bloco lambda passado para assertSoftly termina de executar, o próprio AssertJ chama assertAll() automaticamente por baixo dos panos. Se tanto firstName quanto lastName estiverem incorretos, o relatório de falha exibirá ambas as mensagens de erro simultaneamente!


Resumo

Nesta aula, aprendemos que:

  • Se precisarmos verificar o estado de um objeto que contém múltiplas propriedades correlacionadas, devemos agrupar todas as asserções desse objeto dentro de um único método de teste em vez de espalhá-las em dezenas de testes repetitivos.
  • Se colocarmos múltiplas asserções no mesmo método de teste, devemos implementá-las como Soft Assertions para garantir que todas sejam avaliadas mesmo quando uma delas falha.
  • Se instanciarmos SoftAssertions() manualmente, jamais devemos esquecer de invocar o método assertAll() ao final do método de teste — ou, melhor ainda, devemos preferir SoftAssertions.assertSoftly { softly -> ... } em Kotlin para que a verificação final ocorra automaticamente.