Sobrescrevendo um Builder de Produção com um Test Data Builder

Ao concluirmos esta aula, nós:

  • Saberemos por que devemos sobrescrever um builder de produção com um Test Data Builder específico de teste.
  • Compreenderemos quando devemos aplicar essa técnica em nossos projetos.
  • Seremos capazes de criar um Test Data Builder em Kotlin que utiliza e sobrescreve o builder da classe de produção de maneira transparente.

Os Perigos do Padrão Builder de Produção nos Testes

Embora o padrão Builder (ou construtores restritos de domínio em Kotlin) seja uma excelente ferramenta para criar objetos com estado na aplicação real, ele pode nos causar uma série de problemas quando estamos escrevendo testes automatizados que precisam instanciar novos objetos em estados variados.

A causa raiz desses problemas é que o padrão Builder de produção foi projetado para tornar nosso código de produção seguro e fácil de ler e escrever. Quando implementamos um Builder de domínio, queremos cumprir três objetivos de negócio:

  • Resolver os problemas causados pelo antipadrão do construtor telescópico.
  • Fornecer uma API que nos permita criar um objeto estritamente em seu estado inicial válido.
  • Garantir que seja impossível criar um objeto em um estado inválido ou inconsistente.

Esses são objetivos excelentes para o domínio da aplicação. No entanto, quando cumprimos esses objetivos à risca, nossas escolhas de encapsulamento têm um grande impacto no nosso código de teste. Por exemplo, suponha que a classe Task possua construtor privado e forneça apenas um Builder que garante que só possamos criar tarefas abertas (TaskStatus.OPEN). O código-fonte da classe Task é 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
53
54
55
56
57
58
59
60
61
62
63
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 = Task(this)
    }
}

Quando analisamos essa classe Task, percebemos imediatamente que o Task.Builder de produção é insuficiente para nossos testes porque:

  • Se quisermos criar uma tarefa que já esteja em andamento (TaskStatus.IN_PROGRESS) para testar uma regra de negócio, precisamos descobrir um jeito de alterar o status do objeto Task criado.
  • Se quisermos criar uma tarefa que já esteja fechada (TaskStatus.CLOSED), precisamos descobrir como definir o closer da tarefa criada e alterar tanto o status quanto a resolution da tarefa.

Em outras palavras, os objetivos do padrão Builder de produção são incompatíveis com os requisitos do nosso código de teste. O builder de produção nos protege contra nossos próprios erros no fluxo normal do sistema, mas no código de teste precisamos de uma API limpa e flexível que nos permita posicionar o objeto em qualquer estado do ciclo de vida instantaneamente.

Poderíamos tentar resolver esse problema usando uma destas três opções:

Primeiro, poderíamos adicionar métodos auxiliares nas nossas classes de teste para forçar os valores necessários. Se usarmos essa opção, espalharemos lógica de criação e manipulação de estado pelas classes de teste, tornando os testes difíceis de ler, escrever e manter.

Segundo, poderíamos adicionar novos métodos public (setters ou parâmetros extras no Builder de produção) na classe Task exclusivamente para atender aos testes. Se fizermos isso, estaremos corrompendo o encapsulamento do nosso código de produção apenas para satisfazer o código de teste — permitindo que qualquer código de produção crie tarefas em estados inválidos.

Terceiro, se a classe Task já possuir métodos públicos de transição de estado (como startProgress() e close()), poderíamos invocá-los em sequência nos testes. Porém, o objetivo desses métodos de domínio é impedir transições ilegais (por exemplo, só permitir fechar uma tarefa que primeiro entrou em progresso). Isso nos obrigaria a disparar múltiplas transições de estado em cada setup de teste, tornando a criação de dados mais complexa e acoplada do que deveria ser.

A seguir, apresentarei uma solução limpa que não nos obriga a enfraquecer nem alterar o código de produção: escreveremos um Test Data Builder que sobrescreve o builder de produção.


Criando um Test Data Builder que Sobrescreve o Padrão Builder

Nosso objetivo é criar um TaskBuilder de teste que mantenha exatamente a mesma API fluente que criamos na aula anterior. Por isso, não criaremos nosso novo Test Data Builder do zero; em vez disso, faremos três evoluções passo a passo sobre o TaskBuilder anterior:

Primeiro, precisamos garantir que nosso TaskBuilder seja compatível com a API do Task.getBuilder() de produção seguindo estes passos:

  1. Substituir os campos de objeto assignee, closer, creator e modifier pelos campos de identificadores assigneeId, closerId, creatorId e modifierId (Long?).
  2. Ajustar os métodos withAssignee(), withCreator(), withModifier() e withResolution...() para armazenarem esses IDs.
  3. Garantir que o método build() instancie inicialmente o objeto Task utilizando o Task.getBuilder() fornecido pela classe Task.

Depois de fazermos essas alterações iniciais, o código-fonte do nosso TaskBuilder ficará 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
class TaskBuilder {

    private var assigneeId: Long? = null
    private var closerId: Long? = null
    private var creatorId: Long? = null
    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? = null

    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 {
        return Task.getBuilder()
            .withAssignee(assigneeId)
            .withCreator(creatorId)
            .withDescription(description)
            .withId(id)
            .withTitle(title)
            .build()
    }
}

Segundo, precisamos adicionar um método privado setFieldValue() à classe TaskBuilder. Esse método define o valor de um campo privado de um objeto Task utilizando Reflection (ou ReflectionTestUtils do Spring Test), e recebe três parâmetros:

  • O objeto Task (target) cujo campo será modificado.
  • O nome do campo (fieldName) cujo valor será alterado.
  • O novo valor do campo (fieldValue).

Depois de adicionarmos o método setFieldValue() à classe 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
import java.lang.reflect.Field

class TaskBuilder {

    private var assigneeId: Long? = null
    private var closerId: Long? = null
    private var creatorId: Long? = null
    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? = null

    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 {
        return Task.getBuilder()
            .withAssignee(assigneeId)
            .withCreator(creatorId)
            .withDescription(description)
            .withId(id)
            .withTitle(title)
            .build()
    }

    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)
        }
    }
}

Terceiro, fazemos as alterações finais no método build() do nosso TaskBuilder:

  1. Armazenar o objeto Task criado pelo Task.getBuilder() em uma variável local task.
  2. Definir os valores dos campos closer, modifier, status e resolution utilizando nosso método setFieldValue() via reflection.
  3. Retornar o objeto task configurado.

Depois de realizarmos essas alterações no método build(), o código-fonte completo do nosso TaskBuilder ficará 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
import java.lang.reflect.Field

class TaskBuilder {

    private var assigneeId: Long? = null
    private var closerId: Long? = null
    private var creatorId: Long? = null
    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? = null

    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", status)
        }
        setFieldValue(task, "resolution", 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)
        }
    }
}

Acabamos de criar um Test Data Builder que sobrescreve o builder restrito fornecido pela classe Task. Como não fizemos nenhuma alteração na assinatura pública da API do nosso TaskBuilder, não precisamos alterar uma única linha dos nossos testes unitários existentes! Por exemplo, se quisermos criar uma tarefa fechada como duplicada, continuamos usando exatamente o mesmo código limpo:

 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
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 createTaskThatWasClosedAsDuplicate() {
        val duplicate = TaskBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withModifier(MODIFIER_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .withResolutionDuplicate(CLOSER_ID)
            .build()
    }
}
Alternativa Idiomática em Kotlin (Visibilidade internal): No Kotlin, como o código de produção (src/main/kotlin) e o código de teste (src/test/kotlin) do mesmo módulo Gradle compartilham a visibilidade internal, você também pode marcar um construtor secundário ou setters de estado como internal se a equipe preferir evitar Reflection, mantendo-os invisíveis para módulos externos! Contudo, para entidades JPA ou classes com construtores estritamente privados, o uso encapsulado de Reflection dentro do TaskBuilder garante 100% de blindagem na classe de produção.

Vamos analisar agora os prós e contras do nosso novo Test Data Builder.


Prós e Contras do Nosso Novo Test Data Builder

Os pontos positivos (prós) do nosso Test Data Builder são:

  • Escondemos toda a lógica complexa de criação e ajuste de estado (incluindo Reflection) das pessoas que leem e escrevem os testes. O código de teste permanece limpo e expressivo.
  • Centralizamos a lógica de construção para testes em um único lugar (TaskBuilder). Se a estrutura interna de Task mudar, atualizamos apenas o TaskBuilder, mantendo dezenas de classes de teste intactas.
  • Não precisamos fazer nenhuma concessão ou alteração no código de produção. A classe Task de produção permanece 100% segura e protegida contra estados inválidos.

Os pontos negativos (contras) são:

  • Nosso Test Data Builder utiliza Reflection. Se renomearmos um campo da classe Task sem atualizar a string em setFieldValue, o erro só será percebido ao rodar os testes. Felizmente, os testes falharão imediatamente na IDE com uma mensagem clara (Cannot set the value of the field: ...) e será trivial corrigir em um único ponto.
  • Configurar Reflection dá um pouco mais de trabalho inicial do que expor setters públicos em Task, mas é um código utilitário pequeno que escrevemos apenas uma única vez.
  • Ainda precisamos especificar os campos obrigatórios exigidos pelo builder nas chamadas dos nossos testes, o que pode gerar ruído se o objeto tiver muitos campos obrigatórios. Resolveremos exatamente esse problema na próxima aula deste tópico!

Quando Devemos Usar Esta Técnica?

Na aula anterior, vimos que devemos criar objetos com estado (stateful objects) usando um Test Data Builder quando esses objetos são compartilhados por múltiplas classes de teste e possuem variação demais para simples métodos fábrica.

Nesta aula, comprovamos que, quando a classe de produção restringe a criação de estados através do padrão Builder (ou construtores fechados), sobrescrever o builder de produção com um Test Data Builder nos entrega o melhor dos dois mundos: código de produção blindado e testes fáceis de ler, escrever e manter. As vantagens superam amplamente as pequenas desvantagens.

Vamos resumir o que aprendemos nesta aula.


Resumo

Esta aula nos ensinou três lições fundamentais:

  • Os objetivos do padrão Builder no código de produção (restringir a criação apenas ao estado inicial válido) são frequentemente incompatíveis com os requisitos do código de teste (testar o objeto em qualquer estado do seu ciclo de vida).
  • Devemos sobrescrever um builder de produção com um Test Data Builder quando criamos objetos com estado utilizados por múltiplas classes de teste e não podemos usar um simples método fábrica.
  • Ao sobrescrever um builder de produção com um Test Data Builder, não devemos enfraquecer o encapsulamento do código de produção; em vez disso, encapsulamos o ajuste de campos via Reflection (ou acesso internal) dentro do método build() do nosso Test Data Builder.