Criando Objetos com Estado Consistente em Builders

Ao concluirmos esta aula, nós:

  • Compreenderemos por que não devemos criar objetos com estado (stateful objects) utilizando um test data builder puramente centrado em dados (data-centric).
  • Seremos capazes de criar test data builders que ocultam nossa lógica de negócio por trás de uma API fluente.
  • Saberemos quando devemos criar objetos com estado utilizando um test data builder.

Esta aula assume que:

  • Você está familiarizado com o sistema sob teste (MailingAddress e Task).
  • Você sabe criar dados de teste instanciando objetos diretamente pelo construtor (tanto no antipadrão de construtor telescópico quanto na construção estilo JavaBeans).
  • Você está familiarizado com métodos de fábrica (factory methods).
  • Você sabe criar dados de teste utilizando métodos de fábrica (métodos de fábrica locais e classes Object Mother).
  • Você sabe criar objetos contêineres de dados com test data builders.

As Desvantagens dos Test Data Builders Centrados em Dados

Como lembramos da apresentação do nosso sistema sob teste, a classe Task utiliza a construção no estilo JavaBeans, e um objeto Task pode ter múltiplos status (TaskStatus) e resoluções (TaskResolution). Em outras palavras, um objeto Task não é apenas um contêiner passivo de dados: ele possui um estado interno com regras de consistência que dificultam nossa vida quando criamos dados de teste instanciando a classe manualmente ou usando métodos de fábrica.

Contudo, simplesmente criar um test data builder ingênuo não necessariamente facilita a nossa vida. Por exemplo, se criarmos nosso test data builder para Task seguindo mecanicamente as instruções da aula anterior (um método with... para cada campo), o código-fonte do nosso builder ficará com a seguinte aparência:

 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
class TaskBuilder {

    // Campos internos
    private var assignee: Assignee? = null
    private var closer: Closer? = null
    private var creator: Creator? = null
    private var description: String? = null
    private var id: Long? = null
    private var modifier: Modifier? = null
    private var resolution: TaskResolution? = null
    private var status: TaskStatus? = null
    private var title: String? = null

    // Métodos que configuram os valores dos campos
    fun withAssignee(assigneeId: Long): TaskBuilder {
        this.assignee = Assignee(assigneeId)
        return this
    }

    fun withCloser(closerId: Long): TaskBuilder {
        this.closer = Closer(closerId)
        return this
    }

    fun withCreator(creatorId: Long): TaskBuilder {
        this.creator = Creator(creatorId)
        return this
    }

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

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

    fun withModifier(modifierId: Long): TaskBuilder {
        this.modifier = Modifier(modifierId)
        return this
    }

    fun withResolution(resolution: TaskResolution?): TaskBuilder {
        this.resolution = resolution
        return this
    }

    fun withStatus(status: TaskStatus?): TaskBuilder {
        this.status = status
        return this
    }

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

    // Método que cria um novo objeto Task, atribui os valores
    // dos campos no objeto criado e o retorna.
    fun build(): Task {
        val task = Task()

        task.assignee = assignee
        task.closer = closer
        task.creator = creator
        task.description = description
        task.id = id
        task.modifier = modifier
        task.resolution = resolution
        task.status = status
        task.title = title

        return task
    }
}

Vamos demonstrar os problemas causados por esse test data builder centrado em dados. A seguir, escreveremos dois métodos de teste que criam novos objetos Task que foram fechados como duplicados (DUPLICATE). Esses métodos são:

  • O método buildTaskThatWasClosedAsDuplicate() utiliza o nosso novo test data builder.
  • O método createTaskThatWasClosedAsDuplicate() instancia o objeto Task diretamente e chama os setters.

Depois de adicionarmos esses métodos à nossa classe de teste, seu código-fonte fica com a seguinte aparência:

 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
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 buildTaskThatWasClosedAsDuplicate() {
        val duplicate = TaskBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCloser(CLOSER_ID)
            .withCreator(CREATOR_ID)
            .withModifier(MODIFIER_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .withStatus(TaskStatus.CLOSED)
            .withResolution(TaskResolution.DUPLICATE)
            .build()
    }

    @Test
    fun createTaskThatWasClosedAsDuplicate() {
        val duplicate = Task()
        duplicate.id = ID
        duplicate.assignee = Assignee(ASSIGNEE_ID)
        duplicate.closer = Closer(CLOSER_ID)
        duplicate.creator = Creator(CREATOR_ID)
        duplicate.modifier = Modifier(MODIFIER_ID)
        duplicate.title = TITLE
        duplicate.description = DESCRIPTION
        duplicate.status = TaskStatus.CLOSED
        duplicate.resolution = TaskResolution.DUPLICATE
    }
}

Quando analisamos esses dois métodos de teste, notamos que a única diferença é que o método buildTaskThatWasClosedAsDuplicate() define os valores das propriedades do objeto Task criado usando uma API fluente em vez de atribuir diretamente as propriedades via setters.

É por isso que esse test data builder centrado em dados não facilita em nada a nossa vida. Antes de podermos criar uma tarefa fechada como duplicada, ainda somos obrigados a saber de cabeça todas as seguintes regras de negócio:

  • O responsável (assignee) da tarefa deve ser definido.
  • O usuário que fechou a tarefa (closer) deve ser definido.
  • O criador (creator) da tarefa deve ser definido.
  • O modificador (modifier) da tarefa deve ser definido.
  • O status de uma tarefa fechada deve ser TaskStatus.CLOSED.
  • Como a tarefa foi fechada como duplicada, sua resolução deve ser TaskResolution.DUPLICATE.

Em outras palavras, se criarmos objetos com estado (stateful objects) usando um test data builder puramente centrado em dados, apenas adicionamos complexidade extra à nossa suíte de testes sem receber nada em troca. Esse é um péssimo negócio.

Vamos descobrir como podemos fazer um negócio muito melhor.

Especificando os Requisitos do Nosso Test Data Builder

Quando criamos um novo test data builder para objetos com estado, queremos mover a lógica de criação do objeto (ou seja, a lógica de negócio e invariantes de estado) das nossas classes de teste para dentro do nosso test data builder, fornecendo uma API “à prova de erros” que torne nossos testes fáceis de ler, escrever e manter.

No entanto, antes de podermos fazer isso, precisamos identificar os requisitos concretos do nosso test data builder. Isso é crucial porque requisitos claros nos dão a possibilidade de avaliar nosso test data builder e verificar se ele atende a todas as nossas necessidades.

Podemos identificar os requisitos do nosso test data builder seguindo dois passos:

Primeiro, devemos especificar todos os estados possíveis do objeto criado seguindo estas etapas:

  1. Identificar todos os estados do objeto criado que devem ser suportados pelo nosso test data builder.
  2. Percorrer esses estados um por um e anotar os valores dos campos do objeto que precisam ser definidos em cada estado. Se o valor de um campo for irrelevante para a definição do estado, devemos ignorá-lo nessa análise. Por exemplo, como os valores dos campos title e description de um objeto Task não afetam seu estado de ciclo de vida, nós os ignoraremos nesta etapa.

Depois de concluirmos este passo, teremos uma lista clara dos estados e dos valores de campos que devem ser configurados em cada um. Por exemplo, um objeto Task pode ter os seguintes estados:

Uma tarefa aberta (OPEN):

  • O valor da propriedade assignee deve ser null ou estar definido.
  • O valor da propriedade closer deve ser null.
  • O valor da propriedade creator deve estar definido.
  • O valor da propriedade modifier deve estar definido.
  • O valor da propriedade resolution deve ser null.
  • O valor da propriedade status deve ser TaskStatus.OPEN.

Uma tarefa em andamento (IN_PROGRESS):

  • O valor da propriedade assignee deve estar definido.
  • O valor da propriedade closer deve ser null.
  • O valor da propriedade creator deve estar definido.
  • O valor da propriedade modifier deve estar definido.
  • O valor da propriedade resolution deve ser null.
  • O valor da propriedade status deve ser TaskStatus.IN_PROGRESS.

Uma tarefa fechada (CLOSED):

  • O valor da propriedade assignee deve estar definido.
  • O valor da propriedade closer deve estar definido.
  • O valor da propriedade creator deve estar definido.
  • O valor da propriedade modifier deve estar definido.
  • O valor da propriedade resolution deve ser TaskResolution.DONE, TaskResolution.DUPLICATE ou TaskResolution.WONT_DO.
  • O valor da propriedade status deve ser TaskStatus.CLOSED.

Segundo, devemos especificar os requisitos da API do nosso test data builder seguindo estas quatro etapas:

  1. Identificar o campo (ou campos) que especifica o estado do nosso objeto. Por exemplo, a propriedade status especifica o estado principal de um objeto Task. Como configurar o estado do objeto é a parte mais complexa da lógica de criação, devemos encapsular essa lógica em métodos expressivos adicionados à nossa classe test data builder.
  2. Identificar os campos que são exigidos por múltiplos estados, mas que não “especificam o estado” por si sós. Por exemplo, as propriedades assignee, creator e modifier de um objeto Task são exigidas por múltiplos estados, mas não determinam o estado do objeto (quem faz isso é a propriedade status). Como essas propriedades são exigidas por mais de um estado, devemos definir seus valores utilizando métodos centrados em dados (withAssignee, withCreator, withModifier).
  3. Identificar os campos que são exigidos por apenas um estado e que não são o identificador primário do estado. Por exemplo, as propriedades closer e resolution de um objeto Task só devem ser preenchidas quando a tarefa estiver fechada (CLOSED). Como essas propriedades são exclusivas de um único estado, devemos definir seus valores diretamente dentro do método que configura aquele estado no objeto criado!
  4. Identificar os campos restantes (como id, title e description). Definiremos os valores desses campos utilizando métodos centrados em dados normais.

Depois de concluirmos essas etapas, teremos os requisitos concretos do nosso test data builder. Para um test data builder que cria novos objetos Task, os requisitos são:

  • Nosso test data builder deve possuir métodos centrados em dados que definem os valores das propriedades assignee, creator, description, modifier, id e title.
  • Nosso test data builder deve possuir métodos de estado que configuram o status do objeto Task criado. Esses métodos devem esconder a lógica de negócio complexa dos usuários do nosso test data builder.
  • O usuário do nosso test data builder só poderá definir os valores das propriedades closer e resolution se estiver criando uma tarefa fechada.

A seguir, criaremos um test data builder que cumpre todos esses requisitos.

Criando Nosso Novo Test Data Builder

Podemos criar o nosso novo test data builder aplicando quatro transformações passo a passo ao nosso test data builder centrado em dados:

Eu gosto de começar criando um test data builder centrado em dados e depois modificá-lo para atender aos meus requisitos de estado. Se eu criar alguns objetos de teste com o builder básico antes de fechar os requisitos do builder definitivo, ganho uma compreensão muito melhor do problema que preciso resolver.

Primeiro, devemos remover os métodos isolados que definem os valores das propriedades closer, status e resolution (withCloser(), withStatus() e withResolution()). Depois de fazermos isso, nossa classe test data builder terá apenas os métodos centrados em dados que configuram as propriedades assignee, creator, description, modifier, id e title.

Depois de removermos esses três métodos do nosso test data builder, o código-fonte da nossa classe TaskBuilder fica com a seguinte aparência:

 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
class TaskBuilder {

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

    fun withAssignee(assigneeId: Long): TaskBuilder {
        this.assignee = Assignee(assigneeId)
        return this
    }

    fun withCreator(creatorId: Long): TaskBuilder {
        this.creator = Creator(creatorId)
        return this
    }

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

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

    fun withModifier(modifierId: Long): TaskBuilder {
        this.modifier = Modifier(modifierId)
        return this
    }

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

    fun build(): Task {
        val task = Task()

        task.assignee = assignee
        task.closer = closer
        task.creator = creator
        task.description = description
        task.modifier = modifier
        task.id = id
        task.resolution = resolution
        task.status = status
        task.title = title

        return task
    }
}

Segundo, precisamos escrever um método que deve ser invocado quando quisermos criar uma tarefa aberta (OPEN). Esse método não recebe parâmetros e define o valor da propriedade status como TaskStatus.OPEN. Além disso, como uma tarefa aberta não pode ter um closer nem uma resolution, nosso método garante a consistência do estado definindo os valores dessas duas propriedades como null.

Depois de adicionarmos o método withStatusOpen() à nossa classe test data builder, ela fica com a seguinte aparência:

 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
class TaskBuilder {

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

    fun withAssignee(assigneeId: Long): TaskBuilder {
        this.assignee = Assignee(assigneeId)
        return this
    }

    fun withCreator(creatorId: Long): TaskBuilder {
        this.creator = Creator(creatorId)
        return this
    }

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

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

    fun withModifier(modifierId: Long): TaskBuilder {
        this.modifier = Modifier(modifierId)
        return this
    }

    fun withStatusOpen(): TaskBuilder {
        this.closer = null
        this.resolution = null
        this.status = TaskStatus.OPEN
        return this
    }

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

    fun build(): Task {
        val task = Task()

        task.assignee = assignee
        task.closer = closer
        task.creator = creator
        task.description = description
        task.modifier = modifier
        task.id = id
        task.resolution = resolution
        task.status = status
        task.title = title

        return task
    }
}

Terceiro, precisamos escrever um método que deve ser invocado quando quisermos criar uma tarefa em andamento (IN_PROGRESS). Esse método não recebe parâmetros e define o valor da propriedade status como TaskStatus.IN_PROGRESS. Além disso, como uma tarefa em andamento também não pode ter um closer nem uma resolution, nosso método define os valores dessas propriedades como null.

Depois de adicionarmos o método withStatusInProgress() à nossa classe test data builder, ela fica com a seguinte aparência:

 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
class TaskBuilder {

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

    fun withAssignee(assigneeId: Long): TaskBuilder {
        this.assignee = Assignee(assigneeId)
        return this
    }

    fun withCreator(creatorId: Long): TaskBuilder {
        this.creator = Creator(creatorId)
        return this
    }

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

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

    fun withModifier(modifierId: Long): TaskBuilder {
        this.modifier = Modifier(modifierId)
        return this
    }

    fun withStatusInProgress(): TaskBuilder {
        this.closer = null
        this.resolution = null
        this.status = TaskStatus.IN_PROGRESS
        return this
    }

    fun withStatusOpen(): TaskBuilder {
        this.closer = null
        this.resolution = null
        this.status = TaskStatus.OPEN
        return this
    }

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

    fun build(): Task {
        val task = Task()

        task.assignee = assignee
        task.closer = closer
        task.creator = creator
        task.description = description
        task.modifier = modifier
        task.id = id
        task.resolution = resolution
        task.status = status
        task.title = title

        return task
    }
}

Quarto, precisamos escrever os métodos utilizados para criar tarefas fechadas (CLOSED). Escreveremos três métodos distintos porque uma tarefa fechada pode ter três resoluções diferentes. É claro que poderíamos usar apenas um método e passar o closerId e a resolution como parâmetros. Se fizéssemos isso, nosso método ficaria assim:

1
2
3
4
5
6
fun withStatusClosed(closerId: Long, resolution: TaskResolution): TaskBuilder {
    this.closer = Closer(closerId)
    this.resolution = resolution
    this.status = TaskStatus.CLOSED
    return this
}

Embora usar esse método único já seja muito melhor do que usar os três métodos soltos do nosso builder centrado em dados inicial, acredito que não devemos parar aqui por um motivo simples: nós temos uma opção ainda melhor.

Como sabemos que apenas uma tarefa fechada pode ter uma resolução, podemos identificar a resolução desejada diretamente no nome do nosso método! Isso significa que não precisamos passar o enum TaskResolution como parâmetro do método.

A seguir, escreveremos três métodos diferentes (um para cada resolução) que recebem apenas o identificador do usuário que fechou a tarefa (closerId) como parâmetro. Esses métodos são:

  • O método withResolutionDone() cria uma tarefa que foi fechada porque foi concluída (DONE).
  • O método withResolutionDuplicate() cria uma tarefa que foi fechada como duplicada (DUPLICATE).
  • O método withResolutionWontDo() cria uma tarefa que foi fechada porque não será realizada (WONT_DO).

Depois de adicionarmos esses três métodos à nossa classe test data builder, o código-fonte completo de TaskBuilder fica com a seguinte aparência:

 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
class TaskBuilder {

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

    fun withAssignee(assigneeId: Long): TaskBuilder {
        this.assignee = Assignee(assigneeId)
        return this
    }

    fun withCreator(creatorId: Long): TaskBuilder {
        this.creator = Creator(creatorId)
        return this
    }

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

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

    fun withModifier(modifierId: Long): TaskBuilder {
        this.modifier = Modifier(modifierId)
        return this
    }

    fun withResolutionDone(closerId: Long): TaskBuilder {
        this.closer = Closer(closerId)
        this.resolution = TaskResolution.DONE
        this.status = TaskStatus.CLOSED
        return this
    }

    fun withResolutionDuplicate(closerId: Long): TaskBuilder {
        this.closer = Closer(closerId)
        this.resolution = TaskResolution.DUPLICATE
        this.status = TaskStatus.CLOSED
        return this
    }

    fun withResolutionWontDo(closerId: Long): TaskBuilder {
        this.closer = Closer(closerId)
        this.resolution = TaskResolution.WONT_DO
        this.status = TaskStatus.CLOSED
        return this
    }

    fun withStatusInProgress(): TaskBuilder {
        this.closer = null
        this.resolution = null
        this.status = TaskStatus.IN_PROGRESS
        return this
    }

    fun withStatusOpen(): TaskBuilder {
        this.closer = null
        this.resolution = null
        this.status = TaskStatus.OPEN
        return this
    }

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

    fun build(): Task {
        val task = Task()

        task.assignee = assignee
        task.closer = closer
        task.creator = creator
        task.description = description
        task.modifier = modifier
        task.id = id
        task.resolution = resolution
        task.status = status
        task.title = title

        return task
    }
}

Quando nomeamos esses métodos de transição/configuração de estado, devemos sempre escolher nomes que identifiquem claramente o estado resultante do objeto criado.

Leitura Adicional:

Criamos com sucesso o nosso novo test data builder. Vamos avançar e criar novos objetos Task com ele.

Criando Dados de Teste com Nosso Novo Test Data Builder

Chegou a hora de testar o nosso novo test data builder e avaliar se ele realmente nos ajuda a escrever testes fáceis de ler, escrever e manter. Faremos isso criando três objetos Task diferentes.

Esses objetos Task possuem exatamente os mesmos estados dos objetos Task que criamos quando avaliamos a instanciação direta e os métodos de fábrica nas aulas anteriores. Os exemplos de código a seguir incluem também os métodos de teste antigos lado a lado para que você possa compará-los diretamente.

Primeiro, vamos criar uma tarefa aberta (OPEN) que não está atribuída a ninguém. Depois de criarmos esse objeto Task, o código-fonte da nossa classe de teste fica com a seguinte aparência:

 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
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 = "Test data builder example"
        private const val DESCRIPTION = "Write an example project"
    }

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

    @Test
    fun createOpenTaskWithoutAssigneeNew() {
        val openTask = Task()
        openTask.id = ID
        openTask.assignee = null
        openTask.closer = null
        openTask.creator = Creator(CREATOR_ID)
        openTask.modifier = Modifier(MODIFIER_ID)
        openTask.title = TITLE
        openTask.description = DESCRIPTION
        openTask.status = TaskStatus.OPEN
        openTask.resolution = null
    }

    @Test
    fun createOpenTaskWithoutAssigneeFactory() {
        val openTask = TaskFactory.createOpenTaskWithoutAssignee(
            ID,
            CREATOR_ID,
            MODIFIER_ID,
            TITLE,
            DESCRIPTION
        )
    }
}

Segundo, vamos criar uma tarefa aberta (OPEN) que possui um responsável atribuído (assignee). Depois de criarmos esse objeto Task, o código-fonte da nossa classe de teste fica com a seguinte aparência:

 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
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 = "Test data builder example"
        private const val DESCRIPTION = "Write an example project"
    }

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

    @Test
    fun createOpenTaskThatIsAssignedToAssigneeNew() {
        val assignedTask = Task()
        assignedTask.id = ID
        assignedTask.assignee = Assignee(ASSIGNEE_ID)
        assignedTask.closer = null
        assignedTask.creator = Creator(CREATOR_ID)
        assignedTask.modifier = Modifier(MODIFIER_ID)
        assignedTask.title = TITLE
        assignedTask.description = DESCRIPTION
        assignedTask.status = TaskStatus.OPEN
        assignedTask.resolution = null
    }

    @Test
    fun createOpenTaskThatIsAssignedToAssigneeFactory() {
        val assignedTask = TaskFactory.createOpenTaskThatIsAssignedToAssignee(
            ID,
            ASSIGNEE_ID,
            CREATOR_ID,
            MODIFIER_ID,
            TITLE,
            DESCRIPTION
        )
    }
}

Terceiro, vamos criar uma tarefa que foi fechada como duplicada (DUPLICATE). Depois de criarmos esse objeto Task, o código-fonte da nossa classe de teste fica com a seguinte aparência:

 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
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 = "Test data builder example"
        private const val DESCRIPTION = "Write an example project"
    }

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

    @Test
    fun createTaskThatClosedAsDuplicateNew() {
        val duplicate = Task()
        duplicate.id = ID
        duplicate.assignee = Assignee(ASSIGNEE_ID)
        duplicate.closer = Closer(CLOSER_ID)
        duplicate.creator = Creator(CREATOR_ID)
        duplicate.modifier = Modifier(MODIFIER_ID)
        duplicate.title = TITLE
        duplicate.description = DESCRIPTION
        duplicate.status = TaskStatus.CLOSED
        duplicate.resolution = TaskResolution.DUPLICATE
    }

    @Test
    fun createTaskThatWasClosedAsDuplicateFactory() {
        val duplicate = TaskFactory.createTaskThatWasClosedAsDuplicate(
            ID,
            ASSIGNEE_ID,
            CREATOR_ID,
            CLOSER_ID,
            MODIFIER_ID,
            TITLE,
            DESCRIPTION
        )
    }
}

Quando comparamos nossos novos métodos de teste com os métodos que utilizam instanciação manual (Task()) ou uma classe Object Mother (TaskFactory), percebemos que:

  • Os métodos de teste que utilizam nosso novo test data builder são muito mais fáceis de ler, escrever e manter do que os métodos que instanciam Task() manualmente. Os métodos com instanciação manual até são razoavelmente legíveis, mas o grande problema é que eles são penosos de escrever e manter porque nossa lógica de negócio (quais campos devem ser null, qual status acompanha qual resolution) fica espalhada e duplicada por todos os métodos de teste.
  • Os métodos de teste que utilizam nosso novo test data builder são mais fáceis de ler e escrever do que os métodos que utilizam uma classe Object Mother. Embora a classe Object Mother também esconda a lógica de negócio de quem a chama, seu problema é que os métodos de fábrica acabam exigindo parâmetros demais (de mesmo tipo), dificultando a leitura posicional.

Se quisermos garantir que seja impossível criar objetos aos quais faltem valores de campos obrigatórios, podemos modificar o método build() para lançar uma exceção caso os campos obrigatórios não tenham sido definidos. Por exemplo, se quisermos garantir que ninguém consiga criar uma tarefa sem um criador (creator), podemos escrever um método build() com a seguinte verificação:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fun build(): Task {
    val task = Task()

    task.assignee = assignee
    task.closer = closer
    task.creator = creator
    task.description = description
    task.modifier = modifier
    task.id = id
    task.resolution = resolution
    task.status = status
    task.title = title

    if (task.creator == null) {
        throw NullPointerException(
            "The creator of a task must be set"
        )
    }

    return task
}

(Dica Kotlin: Em Kotlin idiomático, também podemos utilizar requireNotNull(task.creator) { "The creator of a task must be set" } ou checkNotNull(task.creator) { ... } para validar pré-condições e invariantes de estado de forma concisa no método build()).

A seguir, descobriremos quando devemos usar test data builders para criar objetos com estado.

Devemos Usar um Test Data Builder?

Quando decidimos se devemos criar objetos com estado (stateful objects) utilizando um test data builder, precisamos avaliar se os benefícios de usar o builder superam o custo de criar e manter uma classe extra. Podemos tomar essa decisão seguindo três regras:

  • Se não pudermos criar nossos dados de teste usando uma classe Object Mother porque nossos métodos de fábrica teriam parâmetros “demais”, devemos utilizar um test data builder.
  • Se o objeto com estado possuir apenas um único campo cujo valor precisa ser alterado quando mudamos o estado do objeto, não devemos usar um test data builder. Se usarmos um test data builder nessa situação simples, apenas adicionaremos complexidade à nossa suíte de testes sem ganhar nada em troca. Nesse caso, devemos criar nossos dados de teste instanciando o objeto diretamente ou usando um método de fábrica simples.
  • Se os objetos com estado forem utilizados por apenas uma classe de teste ou por pouquíssimas classes de teste, não devemos usar um test data builder. Nessa situação, o custo de criar e manter a classe builder será maior do que os seus benefícios. Devemos preferir a instanciação direta ou um método de fábrica local.

Agora sabemos como criar objetos com estado consistente utilizando um test data builder e sabemos quando aplicar essa técnica em nossas classes de teste. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou cinco coisas essenciais:

  • Não devemos criar objetos com estado (stateful objects) utilizando um test data builder puramente centrado em dados (data-centric).
  • Quando criamos um novo test data builder para um objeto com estado, devemos mover nossa lógica de negócio (regras de transição e consistência entre propriedades) das classes de teste para dentro do test data builder.
  • Se não pudermos criar nossos dados de teste usando uma classe Object Mother porque os métodos de fábrica exigiriam parâmetros demais, devemos usar um test data builder.
  • Se o objeto com estado tiver apenas um campo cujo valor muda conforme o estado do objeto muda, não precisamos de um test data builder.
  • Se os objetos com estado forem usados por apenas uma ou poucas classes de teste, não devemos criar um test data builder.