Enfatizando o Estado Relevante do Objeto Criado

Ao concluirmos esta aula, nós:

  • Compreenderemos por que precisamos enfatizar o estado relevante do objeto criado em nossos testes.
  • Seremos capazes de criar um Test Data Builder que destaca apenas o estado importante usando valores padrão intencionais (insane defaults).
  • Seremos capazes de escrever métodos fábrica estáticos sobre o Test Data Builder que enfatizam o estado do objeto e eliminam o ruído visual.
Esta aula assume que:
No contexto desta aula, o estado de um objeto é definido pelo valor de um campo ou pela combinação de valores de múltiplos campos. Por exemplo, se um objeto Task está aberto (OPEN):
  • O campo closer deve ser null.
  • O valor do campo status deve ser TaskStatus.OPEN.
  • O campo resolution deve ser null.

Excesso de Detalhes Nos Deixa Cegos

Vamos refrescar nossa memória e observar uma classe de teste que possui três métodos de teste:

  • O primeiro método de teste cria uma tarefa aberta que não está atribuída a ninguém.
  • O segundo método de teste cria uma tarefa aberta que está atribuída a uma pessoa específica.
  • O terceiro método de teste cria uma tarefa que foi fechada como duplicada.

O código-fonte atual da nossa classe de teste é o seguinte:

 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
import org.junit.jupiter.api.Test

class TaskTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 66L
        private const val TITLE = "A test data builder example"
        private const val DESCRIPTION = "Write an example project"
    }

    @Test
    fun createOpenTaskWithoutAssignee() {
        val openTask = TaskBuilder()
            .withId(ID)
            .withCreator(CREATOR_ID)
            .withModifier(MODIFIER_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .withStatusOpen()
            .build()
    }

    @Test
    fun createOpenTaskThatIsAssignedToAssignee() {
        val assignedTask = TaskBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withModifier(MODIFIER_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .withStatusOpen()
            .build()
    }

    @Test
    fun createTaskThatWasClosedAsDuplicate() {
        val duplicate = TaskBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withModifier(MODIFIER_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .withResolutionDuplicate(CLOSER_ID)
            .build()
    }
}

Esses métodos de teste fazem sentido se estivermos testando uma funcionalidade de atualização completa que lê todos os campos da tarefa. Contudo, existem muitas situações em que nos importamos apenas com o estado do objeto criado.

Se estivermos escrevendo testes para um componente de negócio que toma decisões baseando-se unicamente no estado da Task (por exemplo, se está aberta ou fechada), não devemos criar nossos dados de teste com todas essas linhas! O código acima possui muito ruído visual (withId, withCreator, withModifier, withTitle, withDescription) que esconde a verdadeira essência do nosso caso de teste.

A seguir, vamos eliminar todo esse ruído passo a passo.


Utilizando Valores Padrão (Default Values)

Podemos nos livrar do ruído desnecessário utilizando uma destas duas abordagens:

Primeiro, se o objeto criado usa construção estilo JavaBeans (setters abertos sem validação no construtor), poderíamos simplesmente deixar de invocar os métodos desnecessários no nosso TaskBuilder:

 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
import org.junit.jupiter.api.Test

class TaskTest {

    companion object {
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
    }

    @Test
    fun createOpenTaskWithoutAssignee() {
        val openTask = TaskBuilder()
            .withStatusOpen()
            .build()
    }

    @Test
    fun createOpenTaskThatIsAssignedToAssignee() {
        val assignedTask = TaskBuilder()
            .withAssignee(ASSIGNEE_ID)
            .withStatusOpen()
            .build()
    }

    @Test
    fun createTaskThatWasClosedAsDuplicate() {
        val duplicate = TaskBuilder()
            .withAssignee(ASSIGNEE_ID)
            .withResolutionDuplicate(CLOSER_ID)
            .build()
    }
}

Nossa classe de teste parece muito mais limpa, mas há um problema sério: os objetos Task criados ficam em um estado estranho e inválido, pois campos obrigatórios de domínio como creator, modifier e title ficam null! Por isso, apenas omitir os campos sem valores padrão não é uma boa solução.

Segundo, se o objeto criado utiliza o padrão Builder de produção (ou invariantes no bloco init {} do Kotlin), é muito provável que o builder de produção valide os campos obrigatórios para impedir a criação de objetos inválidos. Por exemplo, o builder de Task pode garantir que:

  • Não podemos criar uma tarefa sem creator (creator != null).
  • Não podemos criar uma tarefa sem modifier (modifier != null).
  • Não podemos criar uma tarefa com title nulo ou vazio (!title.isNullOrEmpty()).

Se o nosso builder de produção fizer essas checagens de sanidade (sanity checks), o código-fonte da classe Task será semelhante a este:

Task.kt
Arquivo Real KOTLIN
 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
package com.testwithspring.starter.testdata.builder

import com.testwithspring.starter.testdata.Assignee
import com.testwithspring.starter.testdata.Closer
import com.testwithspring.starter.testdata.Creator
import com.testwithspring.starter.testdata.Modifier
import com.testwithspring.starter.testdata.TaskResolution
import com.testwithspring.starter.testdata.TaskStatus

class Task private constructor(builder: Builder) {

    val id: Long? = builder.id
    val assignee: Assignee? = builder.assignee
    var closer: Closer? = null
        private set
    val creator: Creator? = builder.creator
    val description: String? = builder.description
    var modifier: Modifier? = builder.modifier
        private set
    val title: String? = builder.title
    var status: TaskStatus = TaskStatus.OPEN
        private set
    var resolution: TaskResolution? = null
        private set

    companion object {
        @JvmStatic
        fun getBuilder(): Builder = Builder()
    }

    class Builder internal constructor() {
        var id: Long? = null
            private set
        var assignee: Assignee? = null
            private set
        var creator: Creator? = null
            private set
        var description: String? = null
            private set
        var modifier: Modifier? = null
            private set
        var title: String? = null
            private set

        fun withId(id: Long?) = apply {
            this.id = id
        }

        fun withAssignee(assigneeId: Long?) = apply {
            if (assigneeId != null) {
                this.assignee = Assignee(assigneeId)
            }
        }

        fun withCreator(creatorId: Long?) = apply {
            if (creatorId != null) {
                this.creator = Creator(creatorId)
                this.modifier = Modifier(creatorId)
            }
        }

        fun withDescription(description: String?) = apply {
            this.description = description
        }

        fun withTitle(title: String?) = apply {
            this.title = title
        }

        fun build(): Task {
            val task = Task(this)
            checkCreator(task)
            checkModifier(task)
            checkTitle(task)
            return task
        }

        private fun checkCreator(task: Task) {
            if (task.creator == null) {
                throw NullPointerException("Creator cannot be null")
            }
        }

        private fun checkModifier(task: Task) {
            if (task.modifier == null) {
                throw NullPointerException("Modifier cannot be null")
            }
        }

        private fun checkTitle(task: Task) {
            val currentTitle = task.title ?: throw NullPointerException("Title cannot be null")
            check(currentTitle.isNotEmpty()) { "Title cannot be empty" }
        }
    }
}

Quando olhamos para essa classe Task, percebemos que o builder de produção lançará uma exceção se tentarmos chamar .build() sem configurar creator e title.

Felizmente, existe uma solução simples e elegante para esse problema:

Como não queremos invocar métodos irrelevantes em nossos testes, mas precisamos satisfazer os campos obrigatórios da classe Task, definimos valores padrão (default values) diretamente nos campos do nosso TaskBuilder de teste!

Regra de Ouro — Use Valores Padrão "Insanos" (Autoexplicativos):
Devemos sempre usar valores padrão obviamente fictícios ("insanos") no nosso Test Data Builder, pois isso deixa explícito para qualquer pessoa (inclusive ao inspecionar variáveis no debugger ou logs de falha) que aquele caso de teste não se importa com os valores daqueles campos!

Podemos configurar os valores padrão dos campos obrigatórios fazendo duas pequenas alterações no TaskBuilder que criamos na aula anterior:

  1. Definir o valor padrão do campo creatorId como -1L (NOT_IN_USE). Como um ID de banco de dados nunca é negativo em produção, fica óbvio que o caso de teste não se importa com o criador da tarefa.
  2. Definir o valor padrão do campo title como "NOT IMPORTANT". Esse texto deixa imediatamente claro que o título da tarefa é irrelevante para o teste em questão.

Depois de aplicarmos essas mudanças ao nosso TaskBuilder, seu código-fonte fica assim:

TaskBuilder.kt
Arquivo Real KOTLIN
  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
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
package com.testwithspring.starter.testdata.defaults

import com.testwithspring.starter.testdata.Closer
import com.testwithspring.starter.testdata.Modifier
import com.testwithspring.starter.testdata.TaskResolution
import com.testwithspring.starter.testdata.TaskStatus
import com.testwithspring.starter.testdata.builder.Task
import java.lang.reflect.Field

class TaskBuilder {

    companion object {
        private const val NOT_IN_USE = -1L
        private const val NOT_IMPORTANT = "NOT IMPORTANT"

        @JvmStatic
        fun openTaskWithoutAssignee(): Task {
            return TaskBuilder()
                .withStatusOpen()
                .build()
        }

        @JvmStatic
        fun openTaskWithAssignee(assigneeId: Long): Task {
            return TaskBuilder()
                .withAssignee(assigneeId)
                .withStatusOpen()
                .build()
        }

        @JvmStatic
        fun closedAsDuplicate(closerId: Long): Task {
            return TaskBuilder()
                .withResolutionDuplicate(closerId)
                .build()
        }
    }

    private var assigneeId: Long? = null
    private var closerId: Long? = null
    private var creatorId: Long? = NOT_IN_USE
    private var description: String? = null
    private var id: Long? = null
    private var modifierId: Long? = null
    private var resolution: TaskResolution? = null
    private var status: TaskStatus? = null
    private var title: String? = NOT_IMPORTANT

    fun withAssignee(assigneeId: Long?) = apply {
        this.assigneeId = assigneeId
    }

    fun withCreator(creatorId: Long?) = apply {
        this.creatorId = creatorId
    }

    fun withDescription(description: String?) = apply {
        this.description = description
    }

    fun withId(id: Long?) = apply {
        this.id = id
    }

    fun withModifier(modifierId: Long?) = apply {
        this.modifierId = modifierId
    }

    fun withResolutionDone(closerId: Long?) = apply {
        this.closerId = closerId
        this.resolution = TaskResolution.DONE
        this.status = TaskStatus.CLOSED
    }

    fun withResolutionDuplicate(closerId: Long?) = apply {
        this.closerId = closerId
        this.resolution = TaskResolution.DUPLICATE
        this.status = TaskStatus.CLOSED
    }

    fun withResolutionWontDo(closerId: Long?) = apply {
        this.closerId = closerId
        this.resolution = TaskResolution.WONT_DO
        this.status = TaskStatus.CLOSED
    }

    fun withStatusInProgress() = apply {
        this.closerId = null
        this.resolution = null
        this.status = TaskStatus.IN_PROGRESS
    }

    fun withStatusOpen() = apply {
        this.closerId = null
        this.resolution = null
        this.status = TaskStatus.OPEN
    }

    fun withTitle(title: String?) = apply {
        this.title = title
    }

    fun build(): Task {
        val task = Task.getBuilder()
            .withAssignee(assigneeId)
            .withCreator(creatorId)
            .withDescription(description)
            .withId(id)
            .withTitle(title)
            .build()

        if (closerId != null) {
            setFieldValue(task, "closer", Closer(closerId!!))
        }

        if (modifierId != null) {
            setFieldValue(task, "modifier", Modifier(modifierId!!))
        }

        if (status != null) {
            setFieldValue(task, "status", this.status)
        }
        setFieldValue(task, "resolution", this.resolution)

        return task
    }

    private fun setFieldValue(target: Task, fieldName: String, fieldValue: Any?) {
        try {
            val targetField: Field = target.javaClass.getDeclaredField(fieldName)
            targetField.isAccessible = true
            targetField.set(target, fieldValue)
        } catch (ex: Exception) {
            throw RuntimeException("Cannot set the value of the field: $fieldName", ex)
        }
    }
}
Nota de Organização: Se preferir colocar esses métodos fábrica em um object Tasks (padrão Object Mother), sinta-se à vontade! Aqui os colocamos diretamente no companion object do TaskBuilder porque temos apenas três métodos fábrica. Se o número de métodos fábrica crescer, movê-los para um object Tasks dedicado mantém a classe TaskBuilder enxuta e organizada.

Agora podemos atualizar nossos métodos de teste em TaskTest para utilizar nossos novos métodos fábrica:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import org.junit.jupiter.api.Test

class TaskTest {

    companion object {
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
    }

    @Test
    fun createOpenTaskWithoutAssignee() {
        val openTask = TaskBuilder.openTaskWithoutAssignee()
    }

    @Test
    fun createOpenTaskThatIsAssignedToAssignee() {
        val assignedTask = TaskBuilder.openTaskWithAssignee(ASSIGNEE_ID)
    }

    @Test
    fun createTaskThatWasClosedAsDuplicate() {
        val duplicate = TaskBuilder.closedAsDuplicate(CLOSER_ID)
    }
}

Veja como nossos métodos de teste ficaram extraordinariamente limpos! Cada criação ocupa uma única linha autoexplicativa que comunica imediatamente o estado do objeto e o único dado relevante para o teste.


Evidência Real de Execução no Terminal

Execução dos testes com factory methods fluentes no Test Data Builder no Gradle:

Evidência Real de Execução: ./gradlew test --info
Terminal Log
Initialized native services in: /home/wc/.gradle/native
Initialized jansi services in: /home/wc/.gradle/native
Gradle Daemon started in 1 s 106 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
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/main/kotlin]

> Task :processResources NO-SOURCE
> Task :classes
> 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.testdata.defaults.TaskRelevantStateTest > Deve criar tarefa aberta não atribuída enfatizando apenas o status relevante PASSED
com.testwithspring.starter.testdata.defaults.TaskRelevantStateTest > Deve criar tarefa aberta atribuída especificando apenas o ID do responsável PASSED
com.testwithspring.starter.testdata.defaults.TaskRelevantStateTest > Deve criar tarefa aberta não atribuída via Factory Method fluente no Builder PASSED
com.testwithspring.starter.testdata.defaults.TaskRelevantStateTest > Deve criar tarefa aberta atribuída via Factory Method fluente no Builder PASSED
com.testwithspring.starter.testdata.defaults.TaskRelevantStateTest > Deve criar tarefa fechada como duplicada via Factory Method fluente no Builder PASSED

Task - Enfatizando o Estado Relevante do Objeto de Teste > 5 tests completed, 0 failed, 0 skipped
Finished generating test XML results into: /home/wc/test-with-spring/code/01-starter-junit6/build/test-results/test
Finished generating test html results into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test

BUILD SUCCESSFUL in 2s
5 actionable tasks: 5 executed

Quando Devemos Usar Esses Métodos Fábrica?

Recomendo usar um método fábrica sobre um Test Data Builder quando pudermos escrevê-lo com zero ou apenas um parâmetro. Devemos evitar métodos fábrica desse tipo com mais de um parâmetro por duas razões:

  • Quando um método fábrica exige múltiplos parâmetros, seu propósito deixa de ser único e fica difícil dar um nome curto e cristalino que identifique a propriedade mais importante do objeto.
  • O objetivo central dessa técnica é selecionar a propriedade mais importante para o estado testado e colocá-la em destaque absoluto. Se um cenário específico precisar customizar 3 ou 4 propriedades ao mesmo tempo, basta usar a API fluente do próprio TaskBuilder() diretamente naquele teste!

Vamos resumir o que aprendemos nesta aula que encerra o tópico de Criação de Dados de Teste.


Resumo

Esta aula nos ensinou quatro pontos essenciais:

  • Excesso de detalhes irrelevantes na criação de objetos gera ruído visual e esconde a real intenção do caso de teste.
  • Devemos configurar valores padrão nos campos obrigatórios do nosso Test Data Builder para não precisarmos preenchê-los manualmente em todo teste.
  • Devemos sempre escolher valores padrão “insanos” / autoexplicativos (como -1L e "NOT IMPORTANT") para deixar claro que o teste não depende daqueles campos.
  • Podemos combinar métodos fábrica de zero ou um parâmetro com nosso Test Data Builder para enfatizar em uma única linha o estado exato do objeto criado.