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 (
MailingAddresseTask). - 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:
|
|
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 objetoTaskdiretamente e chama os setters.
Depois de adicionarmos esses métodos à nossa classe de teste, seu código-fonte fica com a seguinte aparência:
|
|
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:
- Identificar todos os estados do objeto criado que devem ser suportados pelo nosso test data builder.
- 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
titleedescriptionde um objetoTasknã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
assigneedeve sernullou estar definido. - O valor da propriedade
closerdeve sernull. - O valor da propriedade
creatordeve estar definido. - O valor da propriedade
modifierdeve estar definido. - O valor da propriedade
resolutiondeve sernull. - O valor da propriedade
statusdeve serTaskStatus.OPEN.
Uma tarefa em andamento (IN_PROGRESS):
- O valor da propriedade
assigneedeve estar definido. - O valor da propriedade
closerdeve sernull. - O valor da propriedade
creatordeve estar definido. - O valor da propriedade
modifierdeve estar definido. - O valor da propriedade
resolutiondeve sernull. - O valor da propriedade
statusdeve serTaskStatus.IN_PROGRESS.
Uma tarefa fechada (CLOSED):
- O valor da propriedade
assigneedeve estar definido. - O valor da propriedade
closerdeve estar definido. - O valor da propriedade
creatordeve estar definido. - O valor da propriedade
modifierdeve estar definido. - O valor da propriedade
resolutiondeve serTaskResolution.DONE,TaskResolution.DUPLICATEouTaskResolution.WONT_DO. - O valor da propriedade
statusdeve serTaskStatus.CLOSED.
Segundo, devemos especificar os requisitos da API do nosso test data builder seguindo estas quatro etapas:
- Identificar o campo (ou campos) que especifica o estado do nosso objeto. Por exemplo, a propriedade
statusespecifica o estado principal de um objetoTask. 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. - 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,creatoremodifierde um objetoTasksão exigidas por múltiplos estados, mas não determinam o estado do objeto (quem faz isso é a propriedadestatus). Como essas propriedades são exigidas por mais de um estado, devemos definir seus valores utilizando métodos centrados em dados (withAssignee,withCreator,withModifier). - 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
closereresolutionde um objetoTasksó 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! - Identificar os campos restantes (como
id,titleedescription). 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,idetitle. - Nosso test data builder deve possuir métodos de estado que configuram o
statusdo objetoTaskcriado. 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
closereresolutionse 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 sernull, qualstatusacompanha qualresolution) 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:
|
|
(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.