Boas Práticas para Testes Unitários Aninhados

Ao concluirmos esta aula, nós:

  • Compreenderemos como eliminar código duplicado de setup e dados de teste em nossas suítes.
  • Saberemos como estruturar testes unitários aninhados que são fáceis de ler, escrever e manter.

[!NOTE] Esta aula assume que:


Apresentando as Cinco “Boas Práticas” para Testes Unitários Aninhados

Já sabemos como escrever testes unitários aninhados com @Nested e sabemos que o JUnit invoca os métodos de setup (@BeforeEach) e teardown (@AfterEach) seguindo a hierarquia de classes do método de teste executado. Contudo, ainda precisamos saber como tirar o máximo proveito dessa mecânica no mundo real.

Esta aula apresenta cinco boas práticas fundamentais para estruturar testes unitários aninhados com perfeição.


1. Configure o Sistema Sob Teste no Método de Setup da Classe Raiz

Devemos configurar o Sistema Sob Teste (SUT) no método de setup (@BeforeEach) que fica na classe raiz da nossa hierarquia de classes de teste. Para ser mais específico, esse método de setup na raiz deve fazer apenas duas coisas:

  • Criar todas as dependências do Sistema Sob Teste (usando mockk() ou stub()).
  • Criar uma nova instância da classe testada injetando essas dependências.

Por exemplo, o código-fonte inicial da nossa classe de teste fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
package com.testwithspring.starter.unittests

import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach

class TaskServiceTest {

    private lateinit var repository: TaskRepository
    private lateinit var service: TaskService

    @BeforeEach
    fun configureSUT() {
        repository = mockk(relaxed = true)
        service = TaskService(repository)
    }
}

Como o método anotado com @BeforeEach é executado antes de cada método @Test (em qualquer nível da hierarquia), essa técnica garante que o estado final de um teste unitário concluído jamais interfira na execução do próximo teste unitário, pois cada teste roda contra uma instância nova em folha da classe testada e de seus dublês!

Depois de configurarmos o Sistema Sob Teste, precisamos organizar nossos métodos de teste. Vamos descobrir como criar uma hierarquia de classes que nos ajuda a escrever testes limpos e sem código duplicado.


2. Crie Uma Classe Interna (@Nested) para Cada Método Testado

Depois de configurarmos o Sistema Sob Teste na raiz, a próxima pergunta óbvia é:

Onde devemos colocar nossos métodos de teste?

Devemos seguir estas duas regras:

  • Criamos uma inner class anotada com @Nested para cada método público testado do Sistema Sob Teste.
  • Usamos o nome do método testado (em PascalCase, opcionalmente enriquecido com @DisplayName) como o nome da classe interna.

Se criarmos uma classe de teste que contém testes para os métodos findById() e save() da classe TaskService, o código-fonte da estrutura 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
package com.testwithspring.starter.unittests

import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested

class TaskServiceTest {

    private lateinit var repository: TaskRepository
    private lateinit var service: TaskService

    @BeforeEach
    fun configureSUT() {
        repository = mockk(relaxed = true)
        service = TaskService(repository)
    }

    @Nested
    inner class FindById {

    }

    @Nested
    inner class Save {

    }
}

Essa técnica traz três benefícios imediatos:

  1. Podemos colocar o código de setup específico em seus respectivos contextos (classes internas). Isso torna nossos testes mais fáceis de ler porque criamos um vínculo claro entre o código de setup e os casos de teste daquele método.
  2. Como todas as IDEs permitem expandir e recolher (collapse) classes internas, podemos abrir apenas a classe interna do método que nos interessa no momento e recolher as demais. Isso reduz drasticamente a carga cognitiva durante a leitura.
  3. Colocar nossos casos de teste dentro de classes internas aninhadas nos permite eliminar o código duplicado que torna testes tradicionais difíceis de escrever e manter.

Então, devemos colocar nossos métodos @Test diretamente dentro de FindById e Save? A resposta fica evidente ao analisarmos a próxima boa prática!


3. Separe Diferentes Cenários de Teste Usando Classes Internas (When...)

É muito comum precisarmos escrever testes unitários para um mesmo método que exigem configurações ligeiramente diferentes e possuem expectativas distintas para o resultado esperado.

Por exemplo, se estivermos escrevendo testes unitários para um método de serviço findById() que retorna um objeto Task cujo id é passado como parâmetro, precisamos garantir que nosso código funcione como esperado em dois cenários distintos:

  • Quando o objeto Task não é encontrado.
  • Quando o objeto Task é encontrado.

Antes de escrevermos os métodos @Test propriamente ditos, é uma excelente prática separar esses cenários usando mais um nível de classes internas (@Nested inner class) e nomear essas classes internas adicionando o prefixo When (Quando) à descrição do cenário testado:

  • A classe interna WhenTaskIsNotFound contém o código de teste que garante que o método findById() funciona como esperado quando a tarefa solicitada não é encontrada.
  • A classe interna WhenTaskIsFound contém o código de teste que garante que o método findById() funciona como esperado quando a tarefa solicitada é encontrada.

Depois de adicionarmos essas duas classes internas dentro da classe FindById, o código-fonte da nossa classe 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
package com.testwithspring.starter.unittests

import com.testwithspring.starter.mockk.TestDoubles.stub
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested

class TaskServiceTest {

    private lateinit var repository: TaskRepository
    private lateinit var service: TaskService

    @BeforeEach
    fun configureSUT() {
        repository = stub()
        service = TaskService(repository)
    }

    @Nested
    inner class FindById {

        @Nested
        inner class WhenTaskIsNotFound {

        }

        @Nested
        inner class WhenTaskIsFound {

        }
    }
}

Além disso, se o método findById() possuir algum comportamento comum que é executado independentemente de a tarefa existir ou não e quisermos testar esse comportamento diretamente, colocamos esse método de teste diretamente na classe FindById.

Essa técnica potencializa os três benefícios anteriores:

  • Criamos um vínculo ainda mais forte entre a pré-condição (When…), o código de setup e os casos de teste.
  • Nosso código fica muito fácil de navegar como uma árvore de especificações.
  • Podemos eliminar toda a duplicação colocando cada código de setup no nível exato da árvore! Vamos ver como identificar esse lugar exato na prática 4.

4. Crie Dados de Teste e Configure Dublês de Teste o Mais Alto Possível na Hierarquia

Se quisermos eliminar o código duplicado, precisamos reutilizar o código que cria nossos dados de teste e configura nossos dublês de teste. A melhor maneira de fazer isso é colocar esse código em um método @BeforeEach no nível mais alto possível da hierarquia onde aquela configuração seja compartilhada por todos os testes abaixo dele.

Vamos analisar dois exemplos que ilustram esse princípio com clareza:

Primeiro Exemplo: Configurações Mutuamente Exclusivas (Nível Mais Baixo)

Quando estamos escrevendo testes unitários para o método findById() da classe TaskService, o cenário WhenTaskIsNotFound exige que o repositório retorne Optional.empty(), enquanto o cenário WhenTaskIsFound exige que o repositório retorne Optional.of(found). Como os dois cenários não compartilham a mesma configuração do stub, colocamos cada @BeforeEach nas classes mais internas:

 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
package com.testwithspring.starter.unittests

import com.testwithspring.starter.mockk.TestDoubles.stub
import io.mockk.every
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import java.util.Optional

class TaskServiceTest {

    private lateinit var repository: TaskRepository
    private lateinit var service: TaskService

    @BeforeEach
    fun configureSUT() {
        repository = stub()
        service = TaskService(repository)
    }

    @Nested
    inner class FindById {

        @Nested
        inner class WhenTaskIsNotFound {

            @BeforeEach
            fun returnNoTask() {
                every { repository.findById(1L) } returns Optional.empty()
            }
        }

        @Nested
        inner class WhenTaskIsFound {

            private lateinit var found: Task

            @BeforeEach
            fun returnFoundTask() {
                found = TaskBuilder()
                    .withTitle("Foo")
                    .build()
                every { repository.findById(1L) } returns Optional.of(found)
            }
        }
    }
}

Segundo Exemplo: Configuração Compartilhada por Subcenários (Nível Mais Alto)

Agora suponha que estejamos escrevendo testes unitários para um método methodX() que sempre busca um objeto Task existente no banco de dados antes de tomar decisões secundárias nos subcenários WhenA e WhenB.

Nesse caso, devemos criar o objeto Task e configurar o stub TaskRepository para retorná-lo o mais alto possível na hierarquia — isto é, no @BeforeEach da própria classe MethodX!

Veja como fica o código-fonte:

 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
package com.testwithspring.starter.unittests

import com.testwithspring.starter.mockk.TestDoubles.stub
import io.mockk.every
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import java.util.Optional

class TaskServiceTest {

    private lateinit var repository: TaskRepository
    private lateinit var service: TaskService

    @BeforeEach
    fun configureSUT() {
        repository = stub()
        service = TaskService(repository)
    }

    @Nested
    inner class MethodX {

        private lateinit var found: Task

        @BeforeEach
        fun returnTask() {
            found = TaskBuilder()
                .withTitle("Foo")
                .build()
            every { repository.findById(1L) } returns Optional.of(found)
        }

        @Test
        fun testX() {
        }

        @Nested
        inner class WhenA {

            @BeforeEach
            fun setupA() {
            }

            @Test
            fun testA() {
            }
        }

        @Nested
        inner class WhenB {

            @BeforeEach
            fun setupB() {
            }

            @Test
            fun testB() {
            }
        }
    }
}

Essa técnica proporciona dois grandes benefícios:

  1. Criamos o objeto Task necessário e configuramos o stub TaskRepository apenas uma única vez: Os métodos de teste localizados em MethodX, WhenA e WhenB herdam automaticamente essa mesma configuração! Se amanhã fizermos uma alteração na assinatura do método findById(), precisaremos atualizar apenas um único ponto.
  2. Nossos testes ficam muito mais fáceis de ler: O código de setup fica dividido em métodos @BeforeEach pequenos e coesos em cada nível, deixando nossos métodos @Test curtos e focados exclusivamente na ação (Act) e nas asserções/verificações (Assert).

Avançamos bastante, mas falta falar sobre um aspecto muito importante: onde declarar nossas constantes!


5. Declare Constantes o Mais Baixo Possível na Hierarquia

Quando escrevemos testes unitários tradicionais planos, normalmente colocamos todas as constantes no topo da classe de teste. O problema dessa abordagem é que a classe de teste acaba acumulando uma lista enorme de constantes no topo, e não temos como saber visualmente quais constantes são usadas por quais casos de teste.

Podemos resolver esse problema e vincular nossas constantes diretamente aos casos de teste que as utilizam declarando nossas constantes o mais baixo possível na hierarquia de classes internas!

Por exemplo, ao escrevermos os testes unitários para o método findById() da classe TaskService, declaramos as constantes necessárias seguindo estes dois passos:

  1. Adicionamos a constante TASK_ID = 1L na classe FindById. Declaramos essa constante nesse nível porque ela é compartilhada pelos testes de ambas as classes filhas (WhenTaskIsNotFound e WhenTaskIsFound).
  2. Adicionamos a constante TITLE = "Foo" dentro da classe WhenTaskIsFound. Declaramos essa constante no nível mais baixo porque ela é usada exclusivamente pelos testes dentro de WhenTaskIsFound!

O código-fonte da nossa classe 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
41
42
43
44
45
46
47
48
49
50
package com.testwithspring.starter.unittests

import com.testwithspring.starter.mockk.TestDoubles.stub
import io.mockk.every
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import java.util.Optional

class TaskServiceTest {

    private lateinit var repository: TaskRepository
    private lateinit var service: TaskService

    @BeforeEach
    fun configureSUT() {
        repository = stub()
        service = TaskService(repository)
    }

    @Nested
    inner class FindById {

        private val TASK_ID = 1L

        @Nested
        inner class WhenTaskIsNotFound {

            @BeforeEach
            fun returnNoTask() {
                every { repository.findById(TASK_ID) } returns Optional.empty()
            }
        }

        @Nested
        inner class WhenTaskIsFound {

            private val TITLE = "Foo"

            private lateinit var found: Task

            @BeforeEach
            fun returnFoundTask() {
                found = TaskBuilder()
                    .withTitle(TITLE)
                    .build()
                every { repository.findById(TASK_ID) } returns Optional.of(found)
            }
        }
    }
}

Como podemos ver, nossa classe de teste fica limpa e autoexplicativa porque enxergamos imediatamente que:

  • TASK_ID pertence ao contexto de FindById (usado tanto em WhenTaskIsNotFound quanto em WhenTaskIsFound).
  • TITLE pertence exclusivamente ao contexto de WhenTaskIsFound.

Isoladamente pode parecer um detalhe sutil, mas quando combinamos essa prática com as quatro anteriores, transformamos completamente a legibilidade e a manutenibilidade da nossa suíte de testes!


Resumo

Nesta aula, aprendemos três pilares sobre o design de testes unitários aninhados:

  • Tornamos nossos testes muito mais fáceis de ler criando uma hierarquia de classes @Nested que separa cada método testado (FindById, Save) e cada cenário de pré-condição (WhenTaskIsNotFound, WhenTaskIsFound).
  • Eliminamos código duplicado criando dados de teste e configurando dublês de teste o mais alto possível na hierarquia de classes onde aquela configuração é compartilhada.
  • Vinculamos nossas constantes aos casos de teste que as utilizam declarando-as o mais baixo possível na hierarquia de classes internas.