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:
- 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.
- 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.