Criando Asserções Customizadas de Domínio com AssertJ

Ao concluirmos esta aula, nós:

  • Saberemos como criar asserções customizadas com o AssertJ estendendo AbstractAssert.
  • Compreenderemos quando devemos utilizar asserções customizadas em nossa suíte de testes.

[!NOTE] Esta aula assume que:


Soft Assertions Não Necessariamente Nos Ajudam a Escrever Testes Limpos

Na aula anterior, vimos como agrupar múltiplas asserções usando soft assertions. Suponha que escrevamos um teste que utiliza SoftAssertions para garantir que um objeto Task recém-criado é uma tarefa aberta (open task). O código-fonte desse teste fica assim:

 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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.testwithspring.starter.assertions

import org.assertj.core.api.SoftAssertions
import org.junit.jupiter.api.Test

class TaskTest {

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

    @Test
    fun `build deve criar uma tarefa aberta usando soft assertions`() {
        val task = Task.getBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .build()

        val assertions = SoftAssertions()

        assertions.assertThat(task.status)
            .overridingErrorMessage(
                "The status of an open task must be: %s but was: %s",
                TaskStatus.OPEN,
                task.status
            )
            .isEqualTo(TaskStatus.OPEN)

        assertions.assertThat(task.resolution)
            .overridingErrorMessage(
                "An open task cannot have a resolution but had: %s",
                task.resolution
            )
            .isNull()

        assertions.assertAll()
    }
}

Embora todas as asserções sejam avaliadas juntas graças ao SoftAssertions, esse método de teste ainda possui três problemas não resolvidos:

  1. Ele adiciona lógica de negócio diretamente dentro do método de teste: Isso é uma má ideia. Se alterarmos a regra de negócio que define o que é uma tarefa aberta, teremos que alterar todos os métodos de teste que verificam essa regra. Isso se torna um pesadelo de manutenção.
  2. Ele não é tão fácil de ler quanto testes que possuem apenas uma única asserção: Cada nova asserção adicionada torna o método de teste mais longo e difícil de ler. Para piorar, aplicações reais frequentemente possuem estados complexos que exigem diversas verificações simultâneas. Se colocarmos todas essas verificações diretamente nos métodos de teste, nossos testes parecerão enigmáticos para quem os lê pela primeira vez.
  3. Ele expõe detalhes técnicos de implementação: Isso desvia a atenção do leitor da essência do caso de teste (“a tarefa deve estar aberta”) para detalhes técnicos de baixo nível (“o campo status é OPEN e o campo resolution é null”).

Vamos descobrir como podemos resolver esses três problemas de uma só vez.


Asserções Customizadas ao Resgate

Podemos solucionar nosso problema criando uma classe de asserção customizada (custom assertion class) e movendo nossas asserções de domínio do método de teste para dentro dessa classe. Podemos criar asserções customizadas com o AssertJ em Kotlin seguindo estes cinco passos:

Passo 1: Criar a classe TaskAssert

Primeiro, criamos uma classe TaskAssert. Em Kotlin, todas as classes já são public e final por padrão — o que é excelente, pois impede que outros desenvolvedores estendam nossa classe de asserção indevidamente.

O código-fonte inicial da nossa classe de asserção customizada fica assim:

1
2
3
class TaskAssert {

}

[!TIP] Convenção de Nomenclatura para Classes de Asserção Ao nomear classes de asserção customizadas, uma ótima prática é acrescentar o sufixo Assert ao nome do tipo do objeto verificado. Como nossa classe é usada para escrever asserções sobre objetos Task, seu nome deve ser TaskAssert.

Além disso, se a classe de asserção for usada apenas dentro do mesmo módulo ou pacote, podemos restringir sua visibilidade com internal class TaskAssert.

Passo 2: Estender a classe AbstractAssert

Segundo, modificamos nossa classe de asserção customizada para estender a classe genérica AbstractAssert<S : AbstractAssert<S, A>, A> do AssertJ. Ao estendermos AbstractAssert, devemos fornecer dois parâmetros de tipo:

  1. O próprio tipo da nossa classe de asserção customizada (TaskAssert).
  2. O tipo do objeto que será verificado (Task).

Após estendermos AbstractAssert, o código da classe TaskAssert fica assim:

1
2
3
4
5
import org.assertj.core.api.AbstractAssert

class TaskAssert : AbstractAssert<TaskAssert, Task> {

}

Passo 3: Adicionar o construtor privado

Terceiro, adicionamos um construtor private à classe TaskAssert seguindo estes passos:

  1. Garantimos que o construtor receba o objeto Task real (actual: Task?) como argumento.
  2. Invocamos o construtor da superclasse AbstractAssert, passando dois argumentos:
    • O objeto real (actual).
    • A referência Class que representa nossa classe de asserção customizada (TaskAssert::class.java).

Depois de adicionarmos esse construtor à classe TaskAssert, o código-fonte fica assim:

1
2
3
4
5
6
import org.assertj.core.api.AbstractAssert

class TaskAssert private constructor(actual: Task?) :
    AbstractAssert<TaskAssert, Task>(actual, TaskAssert::class.java) {

}

Passo 4: Adicionar o método fábrica (assertThatTask)

Quarto, adicionamos um método fábrica estático (em um companion object ou como função top-level em Kotlin) que retorna um objeto TaskAssert e recebe o objeto Task verificado como parâmetro.

Após adicionarmos esse método fábrica à classe TaskAssert, o código-fonte fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import org.assertj.core.api.AbstractAssert

class TaskAssert private constructor(actual: Task?) :
    AbstractAssert<TaskAssert, Task>(actual, TaskAssert::class.java) {

    companion object {
        @JvmStatic
        fun assertThatTask(actual: Task?): TaskAssert {
            return TaskAssert(actual)
        }
    }
}

[!NOTE] Por que assertThatTask() em vez de assertThat()? Ao nomear métodos fábrica de asserções customizadas, acrescentar o tipo do objeto verificado ao prefixo assertThat (formando assertThatTask()) evita conflitos de importação estática na IDE quando o mesmo teste utiliza tanto Assertions.assertThat() padrão do AssertJ quanto a nossa asserção customizada.

Passo 5: Implementar o método de asserção isOpen()

Quinto, adicionamos o método de asserção customizado à classe TaskAssert seguindo estes passos:

  1. Adicionamos um método público isOpen(): TaskAssert à classe TaskAssert. Esse método não recebe parâmetros e retorna uma referência ao próprio objeto TaskAssert.
  2. Verificamos que o objeto actual não é nulo (isNotNull) e que ele representa uma tarefa aberta utilizando a classe SoftAssertions (notando que a propriedade herdada actual do AbstractAssert guarda a referência para o Task avaliado).
  3. Retornamos this ao final do método, permitindo encadear múltiplas asserções customizadas de forma fluente.

Depois de adicionarmos o método isOpen() à nossa classe de asserção customizada, o código completo da TaskAssert fica assim:

 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
31
32
33
34
35
36
37
38
import org.assertj.core.api.AbstractAssert
import org.assertj.core.api.SoftAssertions

class TaskAssert private constructor(actual: Task?) :
    AbstractAssert<TaskAssert, Task>(actual, TaskAssert::class.java) {

    companion object {
        @JvmStatic
        fun assertThatTask(actual: Task?): TaskAssert {
            return TaskAssert(actual)
        }
    }

    fun isOpen(): TaskAssert {
        isNotNull

        val assertions = SoftAssertions()

        assertions.assertThat(actual.status)
            .overridingErrorMessage(
                "The status of an open task must be: %s but was: %s",
                TaskStatus.OPEN,
                actual.status
            )
            .isEqualTo(TaskStatus.OPEN)

        assertions.assertThat(actual.resolution)
            .overridingErrorMessage(
                "An open task cannot have a resolution but had: %s",
                actual.resolution
            )
            .isNull()

        assertions.assertAll()

        return this
    }
}

Pronto! Após refatorarmos nosso método de teste para utilizar nossa nova asserção customizada assertThatTask(task).isOpen(), o código-fonte da nossa classe de teste fica extremamente limpo:

 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
import com.testwithspring.starter.assertions.TaskAssert.Companion.assertThatTask
import org.junit.jupiter.api.Test

class TaskTest {

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

    @Test
    fun `build deve criar uma tarefa aberta`() {
        val task = Task.getBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .build()

        assertThatTask(task).isOpen()
    }
}

Vamos avançar e descobrir como devemos nomear nossas asserções customizadas.


Como Devemos Nomear Nossas Asserções Customizadas?

[!NOTE] Esta seção segue os mesmos princípios que vimos para nomear nossos Matchers Hamcrest customizados.

Devemos lembrar que o nome do nosso método na classe TaskAssert é também o “nome” da nossa asserção na DSL fluente lida no teste. Dar bons nomes a métodos de asserção é essencial, e as seguintes perguntas nos ajudam a escolher o melhor nome:

  • A propriedade verificada contém informações que são simplesmente exibidas e editadas? Se sim, considere nomear o método de asserção adicionando o prefixo has ao nome da propriedade (ex.: hasTitle(expectedTitle)).
  • A propriedade verificada contém informações sobre um evento que aconteceu no passado? Se sim, considere iniciar o nome do método de asserção com o prefixo was (ex.: wasCreatedBy(creatorId)).
  • A propriedade (ou conjunto de propriedades) verificada identifica o estado atual do objeto? Se sim, considere iniciar o nome do método de asserção com o prefixo is (ex.: isOpen(), isInProgress()).

É importante lembrar que essas perguntas são diretrizes e não leis rígidas. Seu único propósito é nos ajudar a encontrar o nome mais legível possível, e às vezes devemos abrir exceções quando outro nome soar mais natural no domínio.

Por exemplo, se quisermos criar uma asserção customizada que garante que uma tarefa foi fechada como duplicada (DUPLICATE) por uma pessoa X, um excelente nome é wasClosedWithResolutionDuplicateBy(userId) — mesmo que ela verifique o estado da tarefa.

Vamos avançar e descobrir quando devemos utilizar asserções customizadas.


Quando Devemos Usar Asserções Customizadas?

Antes de criarmos uma asserção customizada, devemos sempre fazer esta pergunta:

Esta asserção tornará o nosso código de teste melhor? Se sim, como?

Como asserções customizadas adicionam código extra para manter em nossa suíte de testes, só devemos utilizá-las quando seus benefícios superarem seus custos. Vamos demonstrar esse princípio comparando dois cenários práticos.

Cenário 1: Asserções Centradas em Dados Simples (Data-Centric)

Se precisarmos escrever uma asserção que apenas garante que o título (title) de uma tarefa está correto, podemos comparar as duas abordagens:

1
2
3
4
5
// Asserção padrão do AssertJ
assertThat(task.title).isEqualTo("title")

// Asserção customizada do AssertJ
assertThatTask(task).hasTitle("title")

Neste caso, o esforço extra de criar um método customizado geralmente não compensa. A asserção customizada é ligeiramente mais fluente, mas a asserção padrão do AssertJ já é perfeitamente clara e legível.

Além disso, há outro motivo pelo qual devemos evitar asserções customizadas para simples propriedades de dados:

Onde paramos? Deveríamos escrever um método de asserção customizado para cada campo de cada classe do sistema? Criar e manter métodos customizados leva tempo, e não devemos desperdiçar esforço quando o retorno sobre o investimento (ROI) é quase nulo.

Portanto, em geral, não devemos criar asserções customizadas para verificações puramente centradas em dados simples — a menos que queiramos destacar um conceito composto importante do domínio (como um endereço completo) ou construir uma linguagem específica de domínio (DSL) rica para encadeamento fluente.

Cenário 2: Asserções que Encapsulam Regras de Negócio e Estado

Agora, se precisarmos garantir que uma tarefa recém-criada é uma tarefa aberta, compare as duas opções:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Asserção padrão do AssertJ (com SoftAssertions)
val assertions = SoftAssertions()

assertions.assertThat(actual.status)
    .overridingErrorMessage(
        "The status of an open task must be: %s but was: %s",
        TaskStatus.OPEN,
        actual.status
    )
    .isEqualTo(TaskStatus.OPEN)

assertions.assertThat(actual.resolution)
    .overridingErrorMessage(
        "An open task cannot have a resolution but had: %s",
        actual.resolution
    )
    .isNull()

assertions.assertAll()

// Asserção customizada do AssertJ
assertThatTask(task).isOpen()

Ao compararmos os dois blocos, fica imediatamente evidente que aqui o esforço extra vale muito a pena por três razões:

  1. A asserção que utiliza assertThatTask(task).isOpen() é drasticamente mais fácil e rápida de ler do que o bloco manual de asserções.
  2. Nossa asserção customizada remove regras de negócio de dentro do método de teste, centralizando-as em um único lugar reutilizável. Se a definição de “tarefa aberta” mudar no futuro, precisaremos atualizar apenas o método isOpen() na classe TaskAssert, em vez de caçar dezenas de métodos de teste espalhados pelo projeto.
  3. Nossa asserção customizada oculta detalhes técnicos de implementação, mantendo o foco do leitor na intenção de negócio do caso de teste.

Resumo

Nesta aula, aprendemos quatro pontos essenciais:

  • Se quisermos criar asserções customizadas com o AssertJ, devemos estender a classe base AbstractAssert.
  • Devemos escolher nomes expressivos para nossos métodos de asserção (has..., was..., is...), pois esses nomes formam a linguagem fluente lida nos testes.
  • Só devemos criar asserções customizadas centradas em dados simples se quisermos destacar propriedades compostas específicas ou construir uma DSL fluente de domínio.
  • Devemos sempre utilizar asserções customizadas para remover regras de negócio e detalhes técnicos de implementação de dentro dos nossos métodos de teste.