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 {} e data class imutá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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.exemplo.tarefas

class TaskTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 66L
        private const val TITLE = "Write an example project"
        private const val DESCRIPTION = "description"
    }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.exemplo.tarefas

import org.junit.jupiter.api.Test

class TaskTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 66L
        private const val TITLE = "Write an example project"
        private const val DESCRIPTION = "description"
    }

    @Test
    fun createOpenTaskWithoutAssignee() {
        val task = Task()
        task.id = ID
        task.assignee = null
        task.closer = null
        task.creator = Creator(CREATOR_ID)
        task.modifier = Modifier(MODIFIER_ID)
        task.description = DESCRIPTION
        task.title = TITLE
        task.status = TaskStatus.OPEN
        task.resolution = null
    }
}

À 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 (resolution deve ser null).
  • O usuário que fechou uma tarefa aberta (closer) deve ser null.
  • 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 ser null.

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.exemplo.tarefas

import org.junit.jupiter.api.Test

class TaskTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 66L
        private const val TITLE = "Write an example project"
        private const val DESCRIPTION = "description"
    }

    @Test
    fun createOpenTaskThatIsAssignedToPerson() {
        val task = Task()
        task.id = ID
        task.assignee = Assignee(ASSIGNEE_ID)
        task.closer = null
        task.creator = Creator(CREATOR_ID)
        task.modifier = Modifier(MODIFIER_ID)
        task.description = DESCRIPTION
        task.title = TITLE
        task.status = TaskStatus.OPEN
        task.resolution = null
    }
}

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 ser null.
  • 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.exemplo.tarefas

import org.junit.jupiter.api.Test

class TaskTest {

    companion object {
        private const val ID = 1L
        private const val ASSIGNEE_ID = 99L
        private const val CLOSER_ID = 55L
        private const val CREATOR_ID = 44L
        private const val MODIFIER_ID = 66L
        private const val TITLE = "Write an example project"
        private const val DESCRIPTION = "description"
    }

    @Test
    fun createTaskThatWasClosedAsDuplicate() {
        val task = Task()
        task.id = ID
        task.assignee = Assignee(ASSIGNEE_ID)
        task.closer = Closer(CLOSER_ID)
        task.creator = Creator(CREATOR_ID)
        task.modifier = Modifier(MODIFIER_ID)
        task.description = DESCRIPTION
        task.title = TITLE
        task.status = TaskStatus.CLOSED
        task.resolution = TaskResolution.DUPLICATE
    }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
val task = Task().apply {
    id = ID
    assignee = Assignee(ASSIGNEE_ID)
    closer = Closer(CLOSER_ID)
    creator = Creator(CREATOR_ID)
    modifier = Modifier(MODIFIER_ID)
    description = DESCRIPTION
    title = TITLE
    status = TaskStatus.CLOSED
    resolution = TaskResolution.DUPLICATE
}

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.

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.