Seleção de Casos de Teste 101: Edição Testes Unitários

Ao concluirmos esta aula, nós:

  • Compreenderemos por que não devemos tentar minimizar artificialmente o número de casos de teste agrupando verificações desconexas em um único método.
  • Saberemos por que a maioria dos nossos testes unitários deve testar apenas uma classe por vez (o mais próximo possível do código testado).
  • Compreenderemos por que devemos implementar nossas funcionalidades de cima para baixo (Top-Down) em vez de construí-las de baixo para cima (Bottom-Up).

Três Dicas Essenciais para Selecionar Nossos Casos de Teste

1. Não Tente Minimizar o Número de Casos de Teste

Quando desenvolvedores escrevem seus primeiros testes unitários, é muito comum criarem métodos de teste gigantescos que tentam verificar coisas demais ao mesmo tempo. Por exemplo, um único método de teste tenta simultaneamente:

  • Verificar as interações que aconteceram entre o Sistema Sob Teste e um mock.
  • Garantir que os objetos passados para os mocks contêm as informações corretas.
  • Verificar se cada propriedade do objeto retornado pelo método testado está correta.

Por exemplo, se precisarmos verificar que o método save() da classe TaskService funciona como esperado e tentarmos minimizar o número de métodos @Test escrevendo tudo em um único teste, o código-fonte da nossa classe de teste ficará parecido com isto:

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

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test

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 Save {

        private lateinit var input: TaskFormDTO

        @BeforeEach
        fun configureTestCase() {
            input = createInput()
            returnCreatedTask(input)
        }

        private fun createInput(): TaskFormDTO {
            return TaskFormDTO(
                title = "title",
                description = "description"
            )
        }

        private fun returnCreatedTask(input: TaskFormDTO) {
            val created = TaskBuilder()
                .withId(1L)
                .withCreator(4L)
                .withTitle("title")
                .withDescription("description")
                .withStatusOpen()
                .build()
            every { repository.save(any()) } returns created
        }

        // ANTI-EXEMPLO: Um único teste tentando validar tudo de uma vez!
        @Test
        fun shouldCreateNewTaskAndReturnInformationOfCreatedTask() {
            val returned = service.save(input)

            verify(exactly = 1) {
                repository.save(withArg { t ->
                    assertThat(t.title).isEqualTo("title")
                    assertThat(t.description).isEqualTo("description")
                })
            }

            assertThat(returned.id).isEqualByComparingTo(1L)

            assertThat(returned.assignee).isNull()
            assertThat(returned.closer).isNull()
            assertThat(returned.creator.id).isEqualByComparingTo(4L)

            assertThat(returned.description).isEqualTo("description")
            assertThat(returned.title).isEqualTo("title")

            assertThat(returned.status).isEqualTo(TaskStatus.OPEN)
            assertThat(returned.resolution).isNull()
        }
    }
}

Esse caso de teste “faz-tudo” possui três problemas sérios:

  1. Primeiro, é praticamente impossível dar um nome curto e descritivo para ele: Um bom nome de teste deve descrever o resultado esperado, mas não conseguimos fazer isso de forma específica quando o teste verifica uma dezena de coisas diferentes ao mesmo tempo.
  2. Segundo, nosso caso de teste é difícil de ler e manter: O teste não possui um objetivo único e claro, tornando o método muito mais longo e denso do que deveria ser.
  3. Terceiro, nosso caso de teste pode falhar por múltiplos motivos diferentes: Um dos principais propósitos dos testes unitários é fornecer diagnóstico imediato sobre a unidade testada quando algo quebra. Como esse teste mistura verificação de mock com diversas asserções sequenciais de retorno, se ele falhar na linha assertThat(returned.id).isEqualByComparingTo(1L), não faremos ideia se as demais propriedades (title, status, creator) estavam corretas ou não! Teremos que corrigir o código e reexecutar o teste várias vezes até descobrir todos os problemas.

Está claro que não devemos escrever testes abarrotados de asserções desconexas. Algumas pessoas defendem a regra extrema de “apenas uma asserção física por método de teste”, mas uma diretriz muito mais pragmática e eficaz é a formulada por Roy Osherove (autor de The Art of Unit Testing):

“Minha diretriz geralmente é testar um CONCEITO lógico por teste. Você pode ter múltiplas asserções sobre o mesmo objeto se elas representarem o mesmo conceito lógico sendo testado.”

O benefício dessa diretriz é que ela nos ajuda a escrever testes unitários curtos, focados e fáceis de ler, escrever e manter. Além disso, como cada teste verifica apenas um conceito lógico, fica muito fácil escolher um nome claro e preciso para cada método de teste!

Por exemplo, aplicando essa diretriz ao método save() da classe TaskService, dividimos as verificações nos seguintes casos de teste:

  • Verificar que o método testado cria e salva uma nova tarefa usando as informações corretas (shouldCreateNewTaskWithCorrectInformation).
  • Verificar que a tarefa retornada possui o ID correto (shouldReturnTaskThatHasCorrectId).
  • Garantir que o método testado retorna uma tarefa com o título correto (shouldReturnTaskThatHasCorrectTitle).
  • Garantir que a tarefa retornada possui a descrição correta (shouldReturnTaskThatHasCorrectDescription).
  • Verificar que a tarefa retornada não possui responsável atribuído (shouldReturnTaskThatHasNoAssignee).
  • Verificar que o criador da tarefa retornada está correto (shouldReturnTaskThatHasCorrectCreator).
  • Garantir que o método testado retorna uma tarefa aberta (shouldReturnOpenTask) — este teste verifica o conceito lógico de “tarefa aberta” (status TaskStatus.OPEN, sem resolution e sem closer).

Depois de refatorarmos nossa classe de teste seguindo essa estrutura, o código-fonte 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
 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
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
package com.testwithspring.starter.unittests

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.SoftAssertions
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test

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 Save {

        private lateinit var input: TaskFormDTO

        @BeforeEach
        fun configureTestCase() {
            input = createInput()
            returnCreatedTask(input)
        }

        private fun createInput(): TaskFormDTO {
            return TaskFormDTO(
                title = "title",
                description = "description"
            )
        }

        private fun returnCreatedTask(input: TaskFormDTO) {
            val created = TaskBuilder()
                .withId(1L)
                .withCreator(4L)
                .withTitle("title")
                .withDescription("description")
                .withStatusOpen()
                .build()
            every { repository.save(any()) } returns created
        }

        @Test
        fun shouldCreateNewTaskWithCorrectInformation() {
            service.save(input)

            verify(exactly = 1) {
                repository.save(withArg { t ->
                    SoftAssertions.assertSoftly { softly ->
                        softly.assertThat(t.title).isEqualTo("title")
                        softly.assertThat(t.description).isEqualTo("description")
                    }
                })
            }
        }

        @Test
        fun shouldReturnTaskThatHasCorrectId() {
            val returned = service.save(input)

            assertThat(returned.id).isEqualByComparingTo(1L)
        }

        @Test
        fun shouldReturnTaskThatHasCorrectTitle() {
            val returned = service.save(input)

            assertThat(returned.title).isEqualTo("title")
        }

        @Test
        fun shouldReturnTaskThatHasCorrectDescription() {
            val returned = service.save(input)

            assertThat(returned.description).isEqualTo("description")
        }

        @Test
        fun shouldReturnTaskThatHasNoAssignee() {
            val returned = service.save(input)

            assertThat(returned.assignee).isNull()
        }

        @Test
        fun shouldReturnTaskThatHasCorrectCreator() {
            val returned = service.save(input)

            assertThat(returned.creator.id).isEqualByComparingTo(4L)
        }

        @Test
        fun shouldReturnOpenTask() {
            val returned = service.save(input)

            // Ou melhor ainda: assertThatTask(returned).isOpen()!
            SoftAssertions.assertSoftly { softly ->
                softly.assertThat(returned.closer).isNull()
                softly.assertThat(returned.status).isEqualTo(TaskStatus.OPEN)
                softly.assertThat(returned.resolution).isNull()
            }
        }
    }
}

É evidente que nossa nova classe de teste é muito mais limpa, legível e diagnóstica do que a anterior! E nos poucos métodos que avaliam múltiplas propriedades de um mesmo conceito lógico (como shouldCreateNewTaskWithCorrectInformation e shouldReturnOpenTask), usamos Soft Assertions (ou nossas Asserções Customizadas do Tópico 03) para garantir que todas as propriedades daquele conceito sejam verificadas juntas.


2. Escreva a Maioria dos Testes o Mais Próximo Possível do Código Testado

Um dos temas mais debatidos em testes unitários é a escolha do tamanho da unidade testada. Existem dois grupos vocais com opiniões fortes sobre isso:

  1. Grupo 1 (Unidade Maior / Caso de Uso): Defende escrever testes unitários focados em um caso de uso completo atravessando múltiplas classes reais (ex.: testar a resolução de uma tarefa invocando o método do TaskService — ou do controlador — com objetos reais de domínio e substituindo apenas a fronteira externa do banco de dados por um dublê).
  2. Grupo 2 (Unidade Menor / Classe e Método Isolado): Defende escrever testes unitários que testam diretamente o método da classe onde a lógica reside (ex.: testar o método resolve() diretamente na própria classe de domínio Task).

A diferença mais óbvia entre essas duas estratégias é o tamanho da unidade sob teste:

  • Os testes da primeira categoria garantem que múltiplas classes funcionem bem quando colaboram em grupo.
  • Os testes da segunda categoria verificam diretamente que os métodos de uma única classe funcionam como esperado.

Antes de mais nada, sejamos absolutamente claros: não existe nenhuma lei que proíba um teste unitário de exercitar mais de uma classe real trabalhando em conjunto (desde que dependências de infraestrutura externa como banco de dados ou rede sejam substituídas por dublês rápidos em memória). Testes de unidade que exercitam um caso de uso são valiosos e rápidos.

No entanto, a maioria dos nossos testes unitários deve testar apenas uma classe por vez, o mais próximo possível de onde a lógica está implementada! Vamos comparar os passos necessários nas duas abordagens para entender o porquê:

  • Se quisermos testar toda a lógica de resolução de uma tarefa (resolve()) apenas através do método do TaskService, precisamos em cada teste:
    1. Criar o objeto TaskService testado e suas dependências (TaskRepository).
    2. Criar o objeto Task que será resolvido.
    3. Configurar o stub/mock do repositório para retornar o objeto Task criado quando o serviço chamar findById().
    4. Invocar o método do TaskService.
    5. Verificar o resultado esperado.
  • Por outro lado, se testarmos o método resolve() diretamente na classe Task (onde a regra de transição de estado realmente mora), precisamos apenas:
    1. Criar o objeto Task que será resolvido.
    2. Invocar o método task.resolve(...).
    3. Verificar o resultado esperado!

Esse exemplo simples demonstra por que devemos escrever a maioria dos nossos testes unitários diretamente na classe que detém a regra: esses testes são drasticamente mais simples, exigem menos código de configuração e não precisam de mocks/stubs intermediários! Por exigirem menos código, eles oferecem o melhor retorno sobre o investimento (ROI) e são muito mais fáceis de escrever, ler e manter.

Em resumo: teste todas as ramificações e casos de borda diretamente na classe onde a lógica reside (ex.: Task.resolve()), e escreva no TaskService apenas os testes necessários para comprovar que o serviço coordena a busca, chama o domínio e persiste o resultado!


3. Escreva o Código de Cima para Baixo (Top-Down)

Quando começamos a implementar uma nova funcionalidade (feature), recebemos (ou definimos junto ao cliente/produto) uma lista de requisitos de negócio que descrevem como aquela funcionalidade deve se comportar.

Podemos implementar qualquer funcionalidade seguindo uma destas duas direções:

  1. De Baixo para Cima (Bottom-Up / “Do alicerce ao telhado”): Criamos primeiro o modelo de dados/tabelas, depois implementamos os repositórios, depois escrevemos as classes de serviço, depois escrevemos os controladores web/REST e, por último, a interface do usuário.
  2. De Cima para Baixo (Top-Down / Outside-In): Começamos pela fronteira mais externa da funcionalidade (a interface de usuário e os controladores web/REST), depois escrevemos as classes de serviço que o controlador precisa, depois o modelo de domínio rico e, por fim, os repositórios de banco de dados.

À primeira vista, pode parecer que a única diferença entre as duas opções é a ordem cronológica das tarefas. Porém, existe uma diferença profunda na qualidade e seleção dos casos de teste:

  • Se implementarmos nossa funcionalidade de baixo para cima (Bottom-Up), é muito difícil conectar nossos casos de teste aos requisitos reais de negócio. Como você está escrevendo um repositório ou entidade antes mesmo de o serviço ou controlador existirem, você acaba escrevendo testes no “piloto automático” para métodos especulativos que talvez nem sejam usados daquela forma, gerando testes desnecessários e desconectados do valor de negócio.
  • Se implementarmos nossa funcionalidade de cima para baixo (Top-Down), os requisitos da camada superior definem exatamente o contrato e os requisitos da camada imediatamente abaixo dela!
    • Ao implementar e testar o Controlador Web/REST, você descobre exatamente quais métodos e contratos o TaskService precisa ter.
    • Ao implementar e testar o TaskService, você descobre exatamente quais métodos de negócio a entidade Task e qual contrato o TaskRepository precisam ter.

Dessa forma, cada caso de teste que você escreve em cada camada pode ser rastreado diretamente até um requisito real de negócio, evitando testes inúteis e mantendo sua suíte enxuta, relevante e fácil de manter!


Resumo

Nesta aula, aprendemos três diretrizes fundamentais para selecionar nossos casos de teste unitário:

  • Devemos escrever testes unitários pequenos que testem um único conceito lógico por método de teste.
  • Devemos escrever a maioria dos nossos testes unitários o mais próximo possível do código testado (testando regras de negócio diretamente nas classes de domínio/valor), pois esses testes são muito mais simples de ler, escrever e manter.
  • Devemos implementar nosso código de cima para baixo (Top-Down), pois isso garante que cada teste escrito reflita uma necessidade real das camadas superiores e possa ser rastreado diretamente até os requisitos de negócio.