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:

TaskConsistentBuilderTest.kt linhas 20–37
Arquivo Real KOTLIN
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
    @Test
    @DisplayName("Deve criar tarefa aberta não atribuída com estado consistente via builder")
    fun createOpenTaskWithoutAssigneeBuilder() {
        val openTask = TaskBuilder()
            .withId(ID)
            .withCreator(CREATOR_ID)
            .withModifier(MODIFIER_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .withStatusOpen()
            .build()

        assertThat(openTask.id).isEqualTo(ID)
        assertThat(openTask.status).isEqualTo(TaskStatus.OPEN)
        assertThat(openTask.assignee).isNull()
        assertThat(openTask.closer).isNull()
        assertThat(openTask.resolution).isNull()
    }

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:

TaskConsistentBuilderTest.kt linhas 39–56
Arquivo Real KOTLIN
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
    @Test
    @DisplayName("Deve criar tarefa aberta atribuída com estado consistente via builder")
    fun createOpenTaskThatIsAssignedToAssigneeBuilder() {
        val assignedTask = TaskBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withModifier(MODIFIER_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .withStatusOpen()
            .build()

        assertThat(assignedTask.id).isEqualTo(ID)
        assertThat(assignedTask.status).isEqualTo(TaskStatus.OPEN)
        assertThat(assignedTask.assignee?.userId).isEqualTo(ASSIGNEE_ID)
        assertThat(assignedTask.closer).isNull()
    }

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:

TaskConsistentBuilderTest.kt linhas 58–75
Arquivo Real KOTLIN
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
    @Test
    @DisplayName("Deve criar tarefa fechada como duplicada com estado consistente via builder")
    fun createTaskThatWasClosedAsDuplicateBuilder() {
        val duplicate = TaskBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withModifier(MODIFIER_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .withResolutionDuplicate(CLOSER_ID)
            .build()

        assertThat(duplicate.id).isEqualTo(ID)
        assertThat(duplicate.status).isEqualTo(TaskStatus.CLOSED)
        assertThat(duplicate.resolution).isEqualTo(TaskResolution.DUPLICATE)
        assertThat(duplicate.closer?.userId).isEqualTo(CLOSER_ID)
    }

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.

Evidência Real de Execução no Terminal

Execução dos testes com o builder de estado consistente 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 108 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.TaskConsistentBuilderTest > Deve criar tarefa aberta não atribuída com estado consistente via builder PASSED
com.testwithspring.starter.testdata.TaskConsistentBuilderTest > Deve criar tarefa aberta atribuída com estado consistente via builder PASSED
com.testwithspring.starter.testdata.TaskConsistentBuilderTest > Deve criar tarefa fechada como duplicada com estado consistente via builder PASSED

Task - Test Data Builder com Estado Consistente > 3 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

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.