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.
- Você está familiarizado com o Sistema Sob Teste (SUT).
- Você sabe como criar objetos de contêiner de dados com Test Data Builders.
- Você sabe como criar objetos com estado (stateful) com Test Data Builders.
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:
|
|
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 ostatusdo objetoTaskcriado. - Se quisermos criar uma tarefa que já esteja fechada (
TaskStatus.CLOSED), precisamos descobrir como definir ocloserda tarefa criada e alterar tanto ostatusquanto aresolutionda 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:
- Substituir os campos de objeto
assignee,closer,creatoremodifierpelos campos de identificadoresassigneeId,closerId,creatorIdemodifierId(Long?). - Ajustar os métodos
withAssignee(),withCreator(),withModifier()ewithResolution...()para armazenarem esses IDs. - Garantir que o método
build()instancie inicialmente o objetoTaskutilizando oTask.getBuilder()fornecido pela classeTask.
Depois de fazermos essas alterações iniciais, o código-fonte do nosso TaskBuilder ficará assim:
|
|
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:
|
|
Terceiro, fazemos as alterações finais no método build() do nosso TaskBuilder:
- Armazenar o objeto
Taskcriado peloTask.getBuilder()em uma variável localtask. - Definir os valores dos campos
closer,modifier,statuseresolutionutilizando nosso métodosetFieldValue()via reflection. - Retornar o objeto
taskconfigurado.
Depois de realizarmos essas alterações no método build(), o código-fonte completo do nosso TaskBuilder ficará assim:
|
|
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:
|
|
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 deTaskmudar, atualizamos apenas oTaskBuilder, 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
Taskde 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
Tasksem atualizar a string emsetFieldValue, 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étodobuild()do nosso Test Data Builder.