Criando Nosso Primeiro Test Data Builder
Ao concluirmos esta aula, nós:
- Saberemos o que é um test data builder (construtor de dados de teste).
- Seremos capazes de criar um test data builder que instancia objetos contêineres de dados (data containers).
- Saberemos identificar os benefícios práticos de utilizar um test data builder.
- Compreenderemos quando devemos usar test data builders para criar objetos contêineres de dados.
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 na classe de teste e classes Object Mother).
O Que É um Test Data Builder?
Um test data builder é uma classe que aplica o padrão de projeto Builder para construir objetos que serão utilizados pelos nossos métodos de teste. Podemos usar test data builders para solucionar os problemas causados pela instanciação direta via construtor (new no Java) e pelos métodos de fábrica tradicionais.
Para ser mais específico, quando criamos um novo test data builder, queremos atingir quatro objetivos principais:
Primeiro, queremos fornecer uma API “à prova de erros” (foolproof API) para a criação dos nossos dados de teste. Se criarmos nossos dados de teste utilizando diretamente construtores posicionais ou métodos de fábrica, muitas vezes acabamos lidando com uma “API lenta” e propensa a equívocos.
Por exemplo, se quisermos criar endereços dos Estados Unidos que possuem um endereço de rua (street address) utilizando diretamente o construtor de MailingAddress e um método de fábrica, o código que cria esses endereços ficaria com a seguinte aparência:
|
|
O problema é que criar nossos dados de teste dessa forma é lento e trabalhoso porque precisamos lembrar de passar os argumentos do construtor ou parâmetros do método na ordem posicional exata. Isso é delicado porque temos que passar argumentos ou parâmetros “demais”. Além disso, a ordem desses argumentos ou parâmetros só parece lógica se o desenvolvedor estiver familiarizado com o formato de endereçamento postal finlandês.
Segundo, queremos tornar imediata e fácil a identificação dos valores das propriedades dos objetos criados. Se quisermos escrever asserções (assertions) para os objetos criados, os valores de cada propriedade desses objetos devem ficar cristalinamente claros para qualquer pessoa que leia o nosso código de teste.
Novamente, se criarmos endereços dos EUA que possuem um endereço de rua utilizando o construtor posicional ou um método de fábrica, teremos o seguinte código:
|
|
O problema aqui é que, se quisermos descobrir quais são exatamente os valores atribuídos a cada campo dos objetos criados (especialmente quando há valores null ou strings curtas), talvez tenhamos que abrir e ler o código-fonte do construtor ou do método de fábrica. Tipicamente, a razão para isso é que pelo menos uma das seguintes condições é verdadeira:
- Nosso código de teste utiliza valores que não fazem sentido semântico. Por exemplo, se quisermos definir o endereço de rua (
streetAddress) de um objetoMailingAddress, jamais deveríamos usar um valor genérico como"FooBar". - O construtor possui argumentos demais e múltiplos argumentos subsequentes possuem o mesmo tipo (
String?). - O método de fábrica possui parâmetros demais e múltiplos parâmetros subsequentes possuem o mesmo tipo (
String). - A ordem dos argumentos do construtor ou dos parâmetros do método só é intuitiva para pessoas que pertencem a um grupo específico. Neste caso, os desenvolvedores precisam conhecer de cor o formato de endereços da Finlândia.
Terceiro, queremos fornecer uma maneira simples e flexível de criar dados de teste que possuem muita variação. O padrão Object Mother (e métodos de fábrica em geral) não lida bem com variações constantes nos dados de teste. Tipicamente, quando precisamos criar dados de teste ligeiramente diferentes usando um Object Mother, simplesmente adicionamos novos métodos à nossa classe Object Mother.
Por exemplo, se precisarmos criar diferentes objetos Task, poderíamos acabar utilizando os seguintes métodos de fábrica:
|
|
O problema é que, se continuarmos adicionando um novo método à nossa classe Object Mother para cada combinação possível de propriedades, ela rapidamente se tornará uma classe inchada, confusa e difícil de manter.
Se não a refatorarmos, nossa classe Object Mother ficará repleta de métodos que criam objetos Task ligeiramente diferentes, contendo uma grande quantidade de código duplicado. Por exemplo, nossa classe Object Mother poderia ficar assim:
|
|
Por outro lado, se refatorarmos nossa classe Object Mother para eliminar a duplicação interna, ela terá vários métodos públicos que simplesmente repassam chamadas para um método privado gigante que instancia o objeto desejado. Isso também é um problema, pois alterações feitas nesse método privado central podem causar efeitos colaterais indesejados em dezenas de testes.
Depois de escrevermos esse método privado auxiliar, nossa classe Object Mother ficaria com a seguinte aparência:
|
|
Quarto, queremos mover nossa lógica de criação de objetos (ou seja, as regras de negócio e invariantes de estado) das nossas classes de teste para um test data builder, pois queremos remover código duplicado da nossa suíte de testes. Ao eliminarmos código duplicado da nossa suíte de testes, desfrutamos de três grandes benefícios:
- Se os objetos criados possuírem diferentes estados, nossos métodos de teste ficam muito mais fáceis de ler, pois a pessoa que lê os testes não precisa conhecer os detalhes de implementação interna de cada estado.
- Fica muito mais fácil escrever novos testes que utilizam os objetos criados, já que não precisamos reimplementar as regras de negócio dentro dos métodos de teste. Em outras palavras, criamos nossos dados de teste escrevendo menos código.
- Se fizermos alterações nas nossas regras de negócio ou na estrutura da classe de domínio, precisaremos atualizar apenas o nosso test data builder. Isso torna nossa suíte de testes drasticamente mais fácil de manter.
Leitura Adicional:
A seguir, criaremos o nosso primeiro test data builder.
Criando Nosso Primeiro Test Data Builder
Vamos criar um test data builder responsável por construir novos objetos MailingAddress. Podemos criar o nosso test data builder seguindo quatro passos:
Primeiro, devemos criar a classe do test data builder seguindo estas etapas:
- Criar uma classe pública (
public) e final (final). No Java, precisávamos declarar explicitamentepublic final class MailingAddressBuilderpara impedir que outros desenvolvedores estendessem a classe. Em Kotlin, todas as classes já sãopublicefinal(fechadas para herança, a menos que marcadas comopen) por padrão! - Garantir um construtor sem argumentos na classe criada (que o Kotlin já fornece automaticamente no cabeçalho da classe).
Depois de criarmos a estrutura inicial da nossa classe test data builder, seu código-fonte fica com a seguinte aparência:
|
|
Há dois pontos que quero destacar:
- Quando nomeio minhas classes de test data builder, adiciono o sufixo
Builderao nome do tipo do objeto criado. Como nossa classe cria objetosMailingAddress, seu nome deve serMailingAddressBuilder. - Este exemplo assume que queremos utilizar nosso test data builder a partir de múltiplas classes de teste que não estão necessariamente no mesmo pacote que o builder. Contudo, caso ele seja usado apenas dentro de um único pacote ou módulo, devemos restringir a visibilidade da classe (por exemplo, usando
internal class MailingAddressBuilder).
Segundo, precisamos adicionar propriedades (campos mutáveis privados) à nossa classe test data builder. Esses campos armazenarão temporariamente os valores que serão repassados ao objeto MailingAddress criado.
Depois de adicionarmos esses campos à nossa classe test data builder, seu código-fonte fica com a seguinte aparência:
|
|
Terceiro, precisamos escrever os métodos que configuram os valores dos campos da nossa classe test data builder. Como queremos construir uma API fluente (fluent API), devemos escrever esses métodos seguindo dois passos:
- Adicionar um método público à classe test data builder que receba o valor do campo como parâmetro e retorne a própria referência do objeto
MailingAddressBuilder. - Implementar o método atribuindo o valor ao campo correspondente e retornando
this(ou usando= apply { ... }em Kotlin).
Depois de adicionarmos esses métodos à nossa classe test data builder, seu código-fonte fica com a seguinte aparência:
|
|
Quando nomeamos esses métodos de configuração no builder, podemos seguir uma destas duas convenções:
- Podemos usar o próprio nome do campo como nome do método:
state(state: String?). - Podemos adicionar o prefixo
withao nome do campo:withState(state: String?).
Embora eu siga a segunda regra (with...) ao longo deste curso porque ela deixa a leitura fluente muito clara em inglês/português, você é livre para escolher a convenção que preferir, desde que mantenha a consistência em toda a sua base de código.
Dica de Kotlin: Em Kotlin, também é muito comum implementar métodos fluentes de builder em uma única linha usando a função de escopo
apply, como em:fun withState(state: String?) = apply { this.state = state }. O resultado e a assinatura pública (MailingAddressBuilder) são exatamente os mesmos!
Quarto, precisamos escrever o método final responsável por instanciar e retornar um novo objeto MailingAddress. Esse método se chama build() e não recebe parâmetros.
Depois de adicionarmos o método build() à nossa classe test data builder, o código-fonte completo de MailingAddressBuilder fica com a seguinte aparência:
|
|
Leitura Adicional:
Acabamos de criar uma classe test data builder simples. Vamos avançar e descobrir como podemos criar novos objetos MailingAddress utilizando o nosso test data builder.
Criando Dados de Teste com um Test Data Builder
Antes de podermos criar dados de teste utilizando o nosso novo test data builder, precisamos criar uma nova classe de teste e declarar todas as constantes necessárias nela.
Depois de criarmos o esqueleto da nossa classe de teste, seu código-fonte fica com a seguinte aparência:
|
|
Para evitar repetições desnecessárias, os exemplos a seguir criam apenas endereços dos Estados Unidos. A mesma mecânica se aplica exatamente da mesma forma para criar endereços da Finlândia ou da Suécia bastando omitir os métodos opcionais (como .withState(...) ou .withCountry(...)).
Vamos criar dois objetos MailingAddress que representam endereços dos Estados Unidos:
- Criar um endereço que possui um endereço de rua (
streetAddress). - Criar um endereço que possui uma caixa postal (
postOfficeBox).
Depois de escrevermos os métodos de teste que constroem esses objetos MailingAddress, o código-fonte da nossa classe de teste fica com a seguinte aparência:
|
|
Criamos dois objetos MailingAddress com sucesso. Agora, vamos comparar nosso código de teste com os trechos de código equivalentes que utilizam o construtor diretamente e um método de fábrica. Relembrando os dois trechos que criam um endereço dos EUA com rua:
|
|
Quando comparamos os exemplos lado a lado, notamos imediatamente que um test data builder resolve os problemas causados pela chamada posicional de construtores e pelos métodos de fábrica. Além disso, percebemos que o uso de um test data builder oferece os seguintes benefícios:
- Os nomes dos métodos da nossa classe test data builder identificam explicitamente os valores de cada campo do objeto criado. Isso torna nosso código mais rápido de escrever, pois não precisamos decorar a ordem de argumentos de construtores ou parâmetros de métodos (nem passar
nullpara campos não utilizados, comopostOfficeBox). Por outro lado, torna nosso código muito mais rápido de ler, já que conseguimos identificar o papel de cada valor simplesmente lendo o método de teste. - Um test data builder oferece suporte de primeira classe para a criação de dados de teste com muita variação. Se quisermos criar objetos contendo combinações diferentes de dados, não precisamos fazer nenhuma alteração na classe
MailingAddressBuilder. Basta encadearmos apenas os métodos relevantes para aquele teste. Por exemplo, podemos usar o mesmíssimoMailingAddressBuilderpara construir endereços finlandeses, suecos e norte-americanos, com rua ou com caixa postal.
A seguir, vamos descobrir quando devemos utilizar test data builders para criar objetos contêineres de dados.
Devemos Usar um Test Data Builder?
Quando decidimos se devemos criar objetos contêineres de dados (data container objects) com um test data builder, precisamos avaliar se os benefícios de usar o builder superam o custo de criar e manter uma classe extra no projeto. Podemos tomar essa decisão seguindo três regras práticas:
- Se a classe contêiner de dados utiliza o antipadrão de construtor telescópico (especialmente comum ao testar classes Java legadas ou bibliotecas externas que não suportam argumentos nomeados ou valores padrão), devemos criar nossos dados de teste utilizando um test data builder. Essa é uma excelente ideia porque o test data builder identifica claramente os valores dos campos do objeto criado e suporta grandes variações nos dados de teste. Em outras palavras, ele resolve os problemas de construtores posicionais e métodos de fábrica, ajudando-nos a escrever testes fáceis de ler, escrever e manter.
- Se a classe contêiner de dados utiliza a construção no estilo JavaBeans (construtor vazio seguido de setters, ou
Task().apply { ... }em Kotlin), não devemos criar um test data builder apenas para repassar campos, pois os próprios métodos setter (ou propriedades no blocoapply) já identificam os campos do objeto criado e já oferecem bom suporte para dados que variam bastante. Ou seja, para um mero contêiner de dados sem regras de estado complexas, um test data builder simples não traz vantagens sobre o estilo JavaBeans. - Se a classe contêiner de dados for utilizada por apenas uma classe de teste ou por pouquíssimas classes de teste, não devemos usar um test data builder mesmo que ela possua construtores telescópicos. Nessa situação, o custo de criar e manter uma classe builder extra provavelmente será maior do que os seus benefícios.
Nota sobre Kotlin Moderno (
data classe argumentos nomeados): Quando a classe contêiner de dados é escrita diretamente em Kotlin utilizandodata class(ou construtores primários em Kotlin), a linguagem já permite invocar o construtor usando argumentos nomeados (MailingAddress(receiver = RECEIVER, streetAddress = STREET_ADDRESS, ...)) e definir valores padrão (= null), o que elimina nativamente a dor dos construtores telescópicos posicionais do Java! Mesmo em Kotlin, porém, os Test Data Builders continuam sendo indispensáveis quando:
- Estamos testando classes Java ou de bibliotecas de terceiros compiladas sem suporte a argumentos nomeados/valores padrão; ou
- Queremos pré-configurar valores padrão sensatos de teste, estados de negócio consistentes e builders aninhados (como veremos nas próximas aulas!).
Agora sabemos como criar objetos contêineres de dados com um test data builder e quando aplicar essa técnica em nossas classes de teste. Vamos resumir o que aprendemos nesta aula.
Resumo
Esta aula nos ensinou seis pontos essenciais:
- Um test data builder identifica claramente os valores dos campos do objeto criado através de sua API fluente.
- Um test data builder oferece excelente suporte para dados de teste que possuem muita variação.
- A desvantagem de usar um test data builder é que precisamos criar uma classe extra que deverá ser mantida no futuro.
- Devemos criar objetos contêineres de dados com um test data builder se a classe utilizar o antipadrão de construtor telescópico.
- Não precisamos criar objetos contêineres de dados puros com um test data builder se a classe já utilizar construção estilo JavaBeans (ou se for uma
data classsimples usada pontualmente com argumentos nomeados). - Não devemos usar um test data builder se a classe contêiner de dados for utilizada apenas por uma ou poucas classes de teste.