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:

 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
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 title = task.title ?: throw NullPointerException("Title cannot be null")
            check(title.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:

  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
import java.lang.reflect.Field

class TaskBuilder {

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

    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)
        }
    }
}
Duas observações importantes:
  • Não precisamos definir um valor padrão separado para modifierId porque o Task.Builder já utiliza o creatorId automaticamente para preencher o modifier inicial da tarefa.
  • Devemos usar valores padrão "insanos" (como -1L e "NOT IMPORTANT") mesmo quando a classe de produção não nos obriga a preencher os campos! Isso garante objetos consistentes e autoexplicativos em qualquer estilo de construção.

Agora podemos remover todo o ruído desnecessário da nossa classe de teste TaskTest com segurança:

 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 já ficou muito mais limpa! Mas podemos deixá-la ainda melhor. A seguir, veremos como enfatizar o estado do objeto criado combinando nosso TaskBuilder com métodos fábrica.


Factory Methods ao Resgate!

Como vimos nas aulas anteriores, métodos fábrica são excelentes para dar nome e enfatizar o estado de um objeto criado, mas sozinhos sofrem quando há muita variação nos dados. Já o Test Data Builder lida muito bem com variação, mas exige múltiplas chamadas encadeadas.

Podemos unir o melhor dos dois padrões escrevendo métodos fábrica que utilizam o nosso TaskBuilder por baixo dos panos!

Vamos adicionar três métodos fábrica estáticos (dentro do companion object) à nossa classe TaskBuilder:

  • openTaskWithoutAssignee(): retorna uma tarefa aberta que não está atribuída a ninguém.
  • openTaskWithAssignee(assigneeId: Long): retorna uma tarefa aberta atribuída à pessoa informada.
  • closedAsDuplicate(closerId: Long): retorna uma tarefa fechada como duplicada pela pessoa especificada.

Depois de adicionarmos esses três métodos fábrica à classe TaskBuilder, seu código-fonte completo 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
114
115
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()
                .withAssignee(NOT_IN_USE)
                .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.


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.