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:
|
|
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.
Evidência Real de Execução no Terminal
Execução dos testes com o builder de estado consistente no Gradle:
Initialized native services in: /home/wc/.gradle/native
Initialized jansi services in: /home/wc/.gradle/native
Gradle Daemon started in 1 s 108 ms
Starting Build
Settings evaluated using settings file '/home/wc/test-with-spring/code/01-starter-junit6/settings.gradle.kts'.
Projects loaded. Root project using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Included projects: [root project 'starter-junit6']
> Configure project :
Evaluating root project 'starter-junit6' using build file '/home/wc/test-with-spring/code/01-starter-junit6/build.gradle.kts'.
Using Kotlin Gradle Plugin 2.4.21
Using Java Toolchain: Temurin 21.0.6 (/home/wc/.sdkman/candidates/java/21.0.6-tem)
Spring Boot 4.1.2 plugin applied.
All projects evaluated.
Selected primary task 'test' from project :
Tasks to be executed: [task ':compileKotlin', task ':compileJava', task ':processResources', task ':classes', task ':compileTestKotlin', task ':compileTestJava', task ':processTestResources', task ':testClasses', task ':test']
Tasks that were skipped: [task ':compileJava', task ':compileTestJava']
> Task :compileKotlin
Compiling with Kotlin compiler version 2.4.21 (JVM target 21)
Compiling Kotlin files from [/home/wc/test-with-spring/code/01-starter-junit6/src/main/kotlin]
> Task :processResources NO-SOURCE
> Task :classes
> Task :compileTestKotlin
Compiling with Kotlin compiler version 2.4.21 (JVM target 21)
Compiling Kotlin files from [/home/wc/test-with-spring/code/01-starter-junit6/src/test/kotlin]
> Task :processTestResources NO-SOURCE
> Task :testClasses
> Task :test
Starting process 'Gradle Test Executor 1'. Working directory: /home/wc/test-with-spring/code/01-starter-junit6 Command: /home/wc/.sdkman/candidates/java/21.0.6-tem/bin/java -ea -cp ...
Successfully started process 'Gradle Test Executor 1'
com.testwithspring.starter.testdata.TaskConsistentBuilderTest > Deve criar tarefa aberta não atribuída com estado consistente via builder PASSED
com.testwithspring.starter.testdata.TaskConsistentBuilderTest > Deve criar tarefa aberta atribuída com estado consistente via builder PASSED
com.testwithspring.starter.testdata.TaskConsistentBuilderTest > Deve criar tarefa fechada como duplicada com estado consistente via builder PASSED
Task - Test Data Builder com Estado Consistente > 3 tests completed, 0 failed, 0 skipped
Finished generating test XML results into: /home/wc/test-with-spring/code/01-starter-junit6/build/test-results/test
Finished generating test html results into: /home/wc/test-with-spring/code/01-starter-junit6/build/reports/tests/test
BUILD SUCCESSFUL in 2s
5 actionable tasks: 5 executed
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.