Padrão Object Mother em Kotlin
Ao concluirmos esta aula, nós:
- Saberemos quando não devemos criar nossos dados de teste com uma classe Object Mother.
- Compreenderemos quando devemos criar nossos dados de teste utilizando uma classe Object Mother.
Esta aula assume que:
- Você está familiarizado com o sistema sob teste (
MailingAddresseTask) apresentado na primeira aula deste tópico. - Você está familiarizado com o conceito de métodos de fábrica (factory methods).
- Você sabe como criar dados de teste utilizando métodos de fábrica locais na própria classe de teste.
Criando uma Nova Classe Object Mother
Uma classe Object Mother é uma estrutura utilitária que contém métodos estáticos (ou funções de um singleton) responsáveis por criar objetos que podemos reutilizar em diversas classes de teste. No Java tradicional, criávamos uma nova classe Object Mother seguindo dois passos:
- Criar uma classe
publicefinal. Marcá-la comofinalera importante para impedir que outros desenvolvedores herdassem dela. - Adicionar um construtor
privateà classe criada para impedir que desenvolvedores instanciassem a classe Object Mother desnecessariamente.
Em Kotlin, todo esse cerimoniário de classe final com construtor private e métodos static é substituído de maneira elegante e idiomática pela palavra-chave object. Uma declaração object em Kotlin define um singleton (final por natureza e sem construtor público invocável) cujas funções podem ser chamadas diretamente pelo nome do objeto.
Depois de criarmos uma classe Object Mother (utilizando object) responsável por criar novos objetos Task, o código-fonte da nossa fábrica fica com a seguinte aparência:
|
|
Nota sobre interoperabilidade Java/Kotlin: Se estivéssemos em um projeto misto (Java e Kotlin), poderíamos anotar os métodos dentro de
object TaskFactorycom@JvmStaticpara que classes de teste legadas em Java os invocassem exatamente como métodos estáticos (TaskFactory.create...()). Em projetos 100% Kotlin com JUnit Jupiter 6, a anotação@JvmStaticnão é necessária aqui.
Há dois pontos importantes que quero destacar:
- Quando nomeamos nossas classes Object Mother, uma convenção bastante clara é adicionar o sufixo
Factory(ouMother) ao tipo do objeto criado. Como nossa classe Object Mother cria objetosTask, seu nome deve serTaskFactory. - Esta aula assume que queremos utilizar esta classe Object Mother a partir de múltiplas classes de teste que não estão necessariamente no mesmo pacote que a classe Object Mother. Caso ela seja usada apenas dentro do mesmo módulo ou pacote, podemos restringir sua visibilidade com
internal object TaskFactory.
Leitura Adicional:
Agora podemos escrever novos métodos de fábrica adicionando funções públicas ao nosso object TaskFactory. A seguir, escreveremos métodos de fábrica que criam novos objetos Task.
Criando Objetos Task com uma Classe Object Mother
Vamos escrever quatro métodos de fábrica diferentes e identificar os prós e os contras de cada abordagem.
Exemplo 1: Uma Tarefa que Foi Fechada como Duplicada
Primeiro, escreveremos um método de fábrica que recebe as informações necessárias como parâmetros do método e cria uma tarefa que foi fechada como duplicada (DUPLICATE). Depois de escrevermos esse método de fábrica, o código-fonte do nosso Object Mother fica com a seguinte aparência:
|
|
Dica de Kotlin Idiomático: No exemplo acima, mantivemos as atribuições diretas via propriedades sintéticas (
task.id = taskId) que mapeiam os setters do padrão JavaBeans da classeTask. Em Kotlin, também poderíamos utilizar a função de escopo.apply { ... }(Task().apply { id = taskId; ... }) para tornar a inicialização ainda mais concisa.
Depois de escrevermos esse método de fábrica, vamos escrever um método de teste que o utiliza. O código-fonte da nossa classe de teste fica com a seguinte aparência:
|
|
Os pontos positivos (prós) deste método de fábrica são:
- Ele possui um bom nome que identifica claramente a essência do objeto criado.
- Ele oculta a lógica que define o status (
TaskStatus.CLOSED) e a resolução (TaskResolution.DUPLICATE) do objetoTaskcriado. - Como o nosso método de fábrica recebe as informações necessárias como parâmetros de método, ele preserva a conexão direta entre o nosso método de teste e os dados de teste.
Este método de fábrica possui uma desvantagem:
Ele possui sete parâmetros de método e vários parâmetros subsequentes possuem exatamente o mesmo tipo (Long e String). Em chamadas posicionais tradicionais, pode ser difícil lembrar a ordem exata dos parâmetros do método e é fácil inverter acidentalmente creatorId com closerId. (Nota: Embora os argumentos nomeados do Kotlin — creatorId = CREATOR_ID — amenizem o risco de troca acidental, ter que passar sete argumentos obrigatórios em todo teste continua sendo trabalhoso quando a maioria deles não importa para o cenário testado).
Exemplo 2: Uma Tarefa Aberta que Está Atribuída a um Responsável
Segundo, escreveremos um método de fábrica que cria uma tarefa aberta que está atribuída a uma pessoa. Como este método de fábrica não possui parâmetros de método, precisamos adicionar as constantes necessárias à nossa classe Object Mother antes de escrevermos o método de fábrica.
Depois de escrevermos este método de fábrica, o código-fonte da nossa classe Object Mother fica com a seguinte aparência:
|
|
Depois de escrevermos este método de fábrica, escreveremos um método de teste que o utiliza. O código-fonte da nossa classe de teste fica com a seguinte aparência:
|
|
Este método de fábrica possui um benefício:
Ele possui um bom nome que enfatiza o fato de que a tarefa criada é uma tarefa aberta (OPEN) que está atribuída a alguém. Isso funciona muito bem se nos importarmos apenas com o fato de que a tarefa criada está aberta e atribuída a uma pessoa, sem nos interessarmos pelos valores exatos dos campos internos do objeto criado.
Este método de fábrica possui uma desvantagem:
Como ele não possui parâmetros de método, ele define os valores dos campos do objeto criado utilizando as constantes privadas declaradas dentro da nossa classe Object Mother. Isso quebra a conexão entre o nosso método de teste e os dados de teste. É por isso que não devemos usar este método de fábrica se o nosso teste depender ou se importar com os valores reais dos campos do objeto criado.
Exemplo 3: Uma Tarefa Aberta que Está Atribuída à Pessoa Especificada
Terceiro, escreveremos um método de fábrica que cria uma tarefa aberta atribuída a uma pessoa específica. Este método recebe o identificador do responsável (assigneeId) como parâmetro. Além disso, como este método de fábrica possui apenas um parâmetro, precisamos manter as constantes necessárias na nossa classe Object Mother para preencher os demais campos.
Depois de escrevermos nosso método de fábrica, o código-fonte da nossa classe Object Mother fica com a seguinte aparência:
|
|
Depois de escrevermos este método de fábrica, escreveremos um método de teste que o utiliza. O código-fonte da nossa classe de teste fica com a seguinte aparência:
|
|
Os pontos positivos (prós) deste método de fábrica são:
- Ele possui um bom nome que enfatiza o fato de criar uma tarefa aberta que está atribuída à pessoa especificada. Isso funciona muito bem quando nos importamos apenas com o estado aberto e com quem é o responsável pela tarefa, sem nos interessarmos pelos demais valores dos campos do objeto criado.
- Ele possui apenas um parâmetro de método, o que destaca visualmente o responsável (
assignee) da tarefa criada dentro do teste.
Este método de fábrica possui uma desvantagem:
Ele define os “outros” valores de campos do objeto criado utilizando as constantes declaradas na nossa classe Object Mother. Isso quebra a conexão entre o nosso método de teste e esses outros dados. Novamente, se o nosso teste se importar com os “outros” valores de campos do objeto criado (como título, criador ou ID da tarefa), não devemos utilizar este método de fábrica.
Exemplo 4: Uma Tarefa Fechada
Quarto, escreveremos um método de fábrica que cria uma tarefa fechada. Este método recebe o identificador de quem fechou a tarefa (closerId) e a resolução (resolution) como parâmetros de método. Além disso, como este método de fábrica possui apenas dois parâmetros, precisamos declarar as constantes necessárias na nossa classe Object Mother antes de implementá-lo.
Depois de escrevermos nosso método de fábrica, o código-fonte da nossa classe Object Mother fica com a seguinte aparência:
|
|
Depois de escrevermos este método de fábrica, escreveremos um método de teste que utiliza o nosso novo método. O código-fonte da nossa classe de teste fica com a seguinte aparência:
|
|
Os pontos positivos (prós) deste método de fábrica são:
- Ele possui um nome apropriado que enfatiza o fato de criar uma tarefa fechada.
- Ele possui apenas dois parâmetros de método que destacam que a tarefa criada foi fechada pela pessoa especificada utilizando a resolução passada como argumento. Isso funciona muito bem se nos importarmos apenas com quem fechou a tarefa e qual foi a resolução utilizada, ignorando os demais valores de campos do objeto criado.
Este método de fábrica possui uma desvantagem:
Ele define os “outros” valores de campos do objeto criado utilizando as constantes internas da nossa classe Object Mother. Isso quebra a conexão entre o nosso método de teste e os dados secundários. Por esse motivo, não devemos usar este método de fábrica caso o nosso teste dependa dos valores exatos desses outros campos.
Agora que escrevemos quatro métodos de fábrica que criam objetos Task, vamos resumir o que aprendemos com esses exemplos.
O Que Esses Exemplos Nos Ensinaram?
Esses exemplos nos ensinaram que devemos prestar atenção especial à API da nossa classe Object Mother. Para ser mais específico, quando adicionamos métodos de fábrica a uma classe Object Mother, precisamos seguir três regras fundamentais:
Primeiro, devemos usar um nome de método que identifique a essência do objeto criado. Embora métodos de fábrica locais também devam ter bons nomes, usar um nome expressivo é extremamente importante quando estamos adicionando um método de fábrica a uma classe Object Mother compartilhada, pois isso nos permite escrever métodos de teste que podem ser compreendidos sem precisar abrir e ler o código-fonte da classe Object Mother.
Segundo, devemos enfatizar todas as propriedades importantes do objeto criado utilizando parâmetros de método. Isso é necessário porque nos ajuda a destacar exatamente as propriedades que são relevantes para os nossos casos de teste. Novamente, devemos lembrar que nosso objetivo é escrever métodos de teste que possam ser lidos de forma autônoma, sem exigir a leitura do código-fonte da nossa classe Object Mother.
Terceiro, devemos documentar nossos métodos de fábrica utilizando KDoc (ou Javadoc). Tipicamente, um método de fábrica só é útil se condições específicas forem atendidas. Se não pudermos documentar todas essas condições apenas através do nome do método de fábrica ou de seus parâmetros, devemos escrever um comentário KDoc que documente claramente o comportamento e os pressupostos do método de fábrica.
Ao utilizarmos KDoc com esse propósito, ajudamos os outros desenvolvedores da equipe a escolherem o método de fábrica correto para o propósito certo. Isso torna nosso código de teste muito mais fácil de escrever e manter. Além disso, com bons comentários KDoc exibidos diretamente no tooltip da IDE, continua sendo possível ler o código de teste sem precisar navegar até a implementação da classe Object Mother, mesmo quando um método de fábrica não tiver uma API perfeita.
Antes de continuarmos, quero destacar dois pontos fundamentais:
- Se uma pessoa que lê nosso código de teste precisar abrir e ler o código-fonte da nossa classe Object Mother, ela terá que abrir outro arquivo na IDE e desviar sua atenção do caso de teste para os detalhes de implementação da fábrica. Devemos fazer o nosso melhor para evitar essa situação, pois a troca de contexto (context switching) tem um custo cognitivo bastante alto.
- O bom senso é o seu ativo mais valioso e espero que você o utilize. A seguir, apresentarei algumas diretrizes que ajudam a decidir se você deve ou não utilizar uma classe Object Mother. Essas regras não são dogmas absolutos (“melhores práticas” cegas), e você deve quebrá-las caso faça sentido no contexto do seu projeto.
A seguir, descobriremos quando não devemos usar uma classe Object Mother.
Quando Não Devemos Usar uma Classe Object Mother
Não devemos utilizar uma classe Object Mother se qualquer uma das seguintes condições for verdadeira:
- Os objetos criados são utilizados por apenas uma classe de teste ou por pouquíssimas classes de teste. Toda decisão arquitetural envolve um trade-off (compensação) e criar/manter uma classe Object Mother tem um custo. Se estivermos nessa situação, o custo de manter uma classe Object Mother externa provavelmente será maior do que os seus benefícios. É por isso que, nesses casos, devemos criar nossos dados de teste utilizando um método de fábrica local na própria classe de teste ou instanciando o objeto diretamente pelo construtor.
- O método de fábrica possui parâmetros “demais”. Não especificarei um número mágico exato porque essa é uma decisão que você mesmo deve tomar com sua equipe. No entanto, quando você começa a cometer erros ao passar argumentos e/ou sente que instanciar novos objetos ficou trabalhoso e confuso, seu método de fábrica muito provavelmente tem parâmetros demais. É por isso que um método de fábrica tradicional não é uma boa escolha quando nossos dados de teste possuem muita variação.
- A ordem dos parâmetros do método só é lógica para pessoas que pertencem a um grupo específico. Isso é importante se o código de teste puder ser lido e/ou escrito por desenvolvedores que não consideram aquela ordem de parâmetros intuitiva. Se nossos testes puderem confundir algumas pessoas da equipe, então eles não são fáceis de ler e escrever.
- O nosso caso de teste utiliza propriedades que não são enfatizadas pelo nome do nosso método de fábrica nem pelos seus parâmetros. Isso é crucial porque queremos escrever testes que possam ser lidos sem precisar consultar o código-fonte da classe Object Mother. Em outras palavras, queremos evitar a troca de contexto (context switching), pois ela encarece a manutenção da suíte de testes.
Vamos avançar e descobrir quando devemos usar uma classe Object Mother.
Quando Devemos Usar uma Classe Object Mother
Devemos utilizar uma classe Object Mother se todas as seguintes condições forem verdadeiras:
- O método de fábrica possui zero ou apenas alguns poucos parâmetros de método. É uma ótima ideia usar um método de fábrica nessa situação SE pudermos identificar imediatamente a essência do objeto criado através do nome do método de fábrica e de seus poucos parâmetros. Além disso, se o método de fábrica não precisa de muitos parâmetros, isso indica que nossos dados de teste não sofrem grandes variações de um teste para outro.
- O nosso caso de teste utiliza apenas propriedades que são enfatizadas pelo nome do método de fábrica e pelos seus parâmetros. É excelente usar uma classe Object Mother nessa situação porque conseguimos escrever testes que são lidos de ponta a ponta sem abrir o código-fonte da classe Object Mother. Ou seja, o leitor do nosso código de teste não precisa pagar o preço da troca de contexto.
- Os objetos criados são reutilizados por múltiplas classes de teste. Devemos usar uma classe Object Mother nessa situação porque ela nos ajuda a eliminar código duplicado espalhado pelas nossas classes de teste. Depois de removermos a duplicação da nossa suíte de testes, notamos imediatamente que nossos testes se tornam muito mais fáceis de escrever e manter.
Agora sabemos como criar novos objetos utilizando uma classe Object Mother e sabemos exatamente quando devemos (ou não) utilizá-la em nossas classes de teste. Vamos resumir o que aprendemos nesta aula.
Resumo
Esta aula nos ensinou três coisas fundamentais:
- Se uma classe Object Mother não tornar nossos testes mais fáceis de ler e escrever, não devemos utilizá-la.
- Devemos usar uma classe Object Mother quando os objetos criados forem compartilhados por múltiplas classes de teste e nossos casos de teste dependerem apenas das propriedades que são claramente enfatizadas pelo nome do método de fábrica e pelos seus parâmetros.
- Devemos sempre confiar no nosso bom senso em vez de seguir cegamente “melhores práticas”.