Construção JavaBeans vs Imutabilidade com Data Classes
Ao concluirmos esta aula, nós:
- Saberemos identificar as desvantagens da construção de objetos no estilo JavaBeans (construtor sem argumentos seguido por mutação de propriedades/setters).
- Compreenderemos quando não devemos utilizar a construção no estilo JavaBeans em nossas classes de teste.
- Saberemos quando devemos utilizar a construção no estilo JavaBeans em nossas classes de teste (e como recursos do Kotlin como
.apply {}edata classimutável se relacionam com esse padrão).
Invocar o construtor de uma classe (usando a palavra-chave new em Java ou invocando Task() diretamente em Kotlin) é a forma básica de instanciar novos objetos, e esta aula não critica sua existência. O objetivo desta aula é mostrar que devemos utilizá-lo no lugar certo.
Esta aula pressupõe que:
Criando Novos Objetos Utilizando a Construção no Estilo JavaBeans
Como vimos na primeira aula deste tópico, a classe Task segue o padrão JavaBeans. Vamos descobrir o que acontece na prática quando criamos novos objetos Task utilizando o construtor sem argumentos e definimos os valores de suas propriedades por meio de métodos setter (ou atribuição direta às propriedades mutáveis var em Kotlin).
Contudo, antes de podermos criar novos objetos Task, precisamos criar uma nova classe de teste com JUnit Jupiter 6 e adicionar todas as constantes necessárias a ela.
Após criarmos nossa classe de teste inicial, seu código-fonte tem a seguinte aparência:
|
|
Agora estamos prontos para criar novos objetos Task. Vamos criar três objetos Task em diferentes estados de negócio e avaliar o que aprendemos com esses exemplos.
Primeiro, se quisermos criar uma tarefa aberta (open task) que não está atribuída a ninguém, precisamos definir os valores das seguintes propriedades:
- Identificador (
id) - Criador (
creator) - Modificador (
modifier) - Título (
title) - Descrição (
description) - Status (
status)
Após escrevermos um método de teste que cria uma tarefa aberta sem responsável atribuído, o código-fonte da nossa classe de teste tem a seguinte aparência:
|
|
À primeira vista, este método de teste parece fácil de ler porque definimos os valores das propriedades do objeto criado utilizando propriedades/setters que possuem nomes claros e explícitos. Entretanto, o problema oculto é que, antes de podermos criar uma tarefa aberta que não está atribuída a ninguém, somos obrigados a conhecer de cor todas estas regras de domínio:
- O status de uma tarefa aberta deve ser
TaskStatus.OPEN. - Uma tarefa aberta não pode ter uma resolução (
resolutiondeve sernull). - O usuário que fechou uma tarefa aberta (
closer) deve sernull. - O criador (
creator) e o modificador (modifier) da tarefa devem ser especificados. - Como a tarefa não está atribuída a ninguém, o responsável (
assignee) deve sernull.
Segundo, se quisermos criar uma tarefa aberta que está atribuída a uma pessoa, precisamos definir os valores das seguintes propriedades:
- Identificador (
id) - Responsável (
assignee) - Criador (
creator) - Modificador (
modifier) - Título (
title) - Descrição (
description) - Status (
status)
Após escrevermos um método de teste que cria uma tarefa aberta atribuída a uma pessoa, o código-fonte da nossa classe de teste tem a seguinte aparência:
|
|
Novamente, este método de teste é fácil de ler linha a linha porque as propriedades (ou métodos setter) possuem nomes adequados. Dito isso, o problema persiste: antes de podermos criar uma tarefa aberta atribuída a uma pessoa, precisamos saber e lembrar que:
- O status de uma tarefa aberta deve ser
TaskStatus.OPEN. - Uma tarefa aberta não pode ter uma resolução (
resolution = null). - O usuário que fechou uma tarefa aberta (
closer) deve sernull. - O responsável (
assignee), o criador (creator) e o modificador (modifier) da tarefa devem ser especificados.
Terceiro, se quisermos criar uma tarefa que foi fechada como duplicada, precisamos definir os valores das seguintes propriedades:
- Identificador (
id) - Responsável (
assignee) - Usuário que fechou (
closer) - Criador (
creator) - Modificador (
modifier) - Título (
title) - Descrição (
description) - Status (
status) - Resolução (
resolution)
Após escrevermos um método de teste que cria uma tarefa fechada como duplicada, o código-fonte da nossa classe de teste tem a seguinte aparência:
|
|
Este método de teste continua sendo fácil de ler superficialmente, mas o problema é que acabamos de aumentar ainda mais a quantidade de regras de negócio que precisamos memorizar nos testes. Antes de podermos criar uma tarefa fechada como duplicada, precisamos saber que:
- O status de uma tarefa fechada deve ser
TaskStatus.CLOSED. - Como a tarefa foi fechada como duplicada, sua resolução deve ser
TaskResolution.DUPLICATE. - O responsável (
assignee), quem fechou (closer), o criador (creator) e o modificador (modifier) da tarefa devem ser especificados.
Esses três exemplos demonstram que, quando utilizamos a construção no estilo JavaBeans diretamente nos testes, escrevemos código centrado em dados (data-centric code). Nós simplesmente instanciamos novos objetos vazios e preenchemos propriedade por propriedade. Embora o código pareça fácil de ler isoladamente, ele possui dois problemas graves:
Primeiro, ele não descreve o significado semântico da combinação dos valores das propriedades. Isso significa que a pessoa que lê e escreve esse código precisa conhecer profundamente todos os estados válidos de uma tarefa. Em outras palavras, essa pessoa precisa fazer um esforço mental constante para mapear diferentes combinações de propriedades em um estado específico de negócio (e vice-versa). Isso torna o nosso código de teste mais difícil de ler e de escrever.
Segundo, a lógica que cria novos objetos Task em estados válidos fica espalhada por todas as classes de teste que utilizam objetos Task. Se precisarmos fazer alterações nas regras dos estados existentes da tarefa (por exemplo, adicionar um campo obrigatório de data de fechamento closedAt sempre que status == TaskStatus.CLOSED), teremos que fazer essas mesmas alterações manualmente em cada classe de teste que instancia Task. Isso torna nossos testes difíceis de manter, pois aplicar essas mudanças consome muito tempo. Além disso, torna nossos testes muito mais propensos a erros: se esquecermos de atualizar a configuração em algum teste, passaremos a ter uma suíte de testes enganosa que testa objetos em estados inválidos que jamais existiriam em produção.
Nota sobre Kotlin: .apply { ... } e data class com copy()
Em Kotlin, quando lidamos com classes estilo JavaBeans (como entidades JPA ou classes de bibliotecas Java), é muito comum utilizarmos a função de escopo .apply { ... } para evitar repetir a variável task. em cada linha:
|
|
E quando modelamos nossas classes de domínio de forma imutável usando data class com propriedades val, evitamos a mutabilidade descontrolada do padrão JavaBeans e ganhamos o método .copy(). Contudo, repare que usar .apply {} ou construtores de data class diretamente em cada método de teste não resolve os dois problemas arquiteturais apontados acima: as regras de transição de estado (quais campos devem ser null ou preenchidos quando uma tarefa está aberta ou fechada) continuam vazando e se repetindo por toda a suíte de testes!
Vamos avançar e descobrir quando não devemos utilizar a construção no estilo JavaBeans em nossas classes de teste.
Quando Não Devemos Utilizar a Construção no Estilo JavaBeans
Não devemos utilizar a construção no estilo JavaBeans diretamente em nossas classes de teste se qualquer uma das seguintes condições for verdadeira:
- O objeto criado possui diferentes estados de negócio. Não devemos utilizar a construção no estilo JavaBeans nessa situação porque a lógica de criação de objetos (ou seja, nossas regras de negócio sobre o que constitui um estado válido) fica espalhada por todas as classes de teste que utilizam os objetos criados. Isso torna nossos testes frágeis e difíceis de manter.
- Nossa suíte de testes possui múltiplas classes de teste que exigem objetos com valores de propriedades iguais ou ligeiramente diferentes. Essa é uma situação delicada, pois código duplicado torna nossos testes difíceis de manter. Por outro lado, às vezes escrever um pouco de código duplicado vale a pena se tornar um teste pontual mais fácil de ler. Em outras palavras, quando estivermos nessa situação, devemos comparar os prós e os contras das nossas opções (como Factory Methods ou Test Data Builders) antes de decidir o que fazer.
A seguir, descobriremos quando devemos utilizar a construção no estilo JavaBeans em nossas classes de teste.
Quando Devemos Utilizar a Construção no Estilo JavaBeans
Devemos utilizar a construção no estilo JavaBeans diretamente em nossas classes de teste se qualquer uma das seguintes condições for verdadeira:
- O objeto criado é utilizado apenas para transferir dados (DTO / Data Transfer Object). Se o objeto for apenas um contêiner de dados puro e não possuir invariantes ou estados de negócio, podemos escrever código centrado em dados (como
FormDTO().apply { ... }) sem receio. - Os objetos criados são utilizados por apenas uma classe de teste ou por poucas classes de teste. Toda decisão é um trade-off e criar abstrações auxiliares tem um custo. Se estivermos nessa situação, o custo de criar e manter outras opções provavelmente será maior do que os benefícios. Por isso, devemos criar nossos dados de teste diretamente usando a construção padrão da classe.
- Não nos importamos com os valores das propriedades nem com o estado do objeto criado. Às vezes, queremos apenas garantir que um campo de um objeto faça referência à instância correta (ou passar um objeto dummy que não será inspecionado). Se esse for o caso, e pudermos criar o objeto referenciado usando um construtor sem argumentos (
Task()), devemos instanciá-lo diretamente em nossa classe de teste. A razão para isso é que usar outras opções, como um método fábrica (factory method) ou um test data builder, seria “caro” demais para um objeto cujos atributos são irrelevantes para o teste.
Agora sabemos identificar as desvantagens da construção no estilo JavaBeans e sabemos quando devemos (e quando não devemos) utilizá-la em nossas classes de teste. Vamos resumir o que aprendemos nesta aula.
Evidência Real de Execução no Terminal
Execução dos testes demonstrando a instanciação estilo JavaBeans e escopos em Kotlin:
Initialized native services in: /home/wc/.gradle/native
Initialized jansi services in: /home/wc/.gradle/native
Gradle Daemon started in 1 s 112 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.TaskJavaBeansTest > Deve criar tarefa fechada como duplicada utilizando padrão JavaBeans com setters PASSED
com.testwithspring.starter.testdata.TaskJavaBeansTest > Deve criar tarefa aberta atribuída utilizando padrão JavaBeans PASSED
com.testwithspring.starter.testdata.TaskJavaBeansTest > Deve criar tarefa aberta não atribuída utilizando padrão JavaBeans PASSED
com.testwithspring.starter.testdata.TaskJavaBeansTest > Deve criar e configurar objeto Task utilizando função de escopo idiomatica .apply {} PASSED
Task - Construção Estilo JavaBeans vs Imutabilidade > 4 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
Resumo
Esta aula nos ensinou quatro pontos essenciais:
- A construção no estilo JavaBeans nos força a espalhar regras de negócio e invariantes de estado dentro das nossas classes de teste.
- Não devemos utilizar a construção no estilo JavaBeans diretamente em nossas classes de teste se o objeto criado possuir diferentes estados de negócio.
- Devemos utilizar a construção no estilo JavaBeans em nossas classes de teste se o objeto criado for apenas um contêiner de transferência de dados (sem estados complexos) ou se seus atributos forem irrelevantes para o teste.
- Se os prós da construção direta no estilo JavaBeans superarem os seus contras (por exemplo, quando usada em apenas uma classe de teste), devemos utilizá-la sem complicar o código.