Criando Matchers Customizados Reutilizáveis no Hamcrest

Ao concluirmos esta aula, nós:

  • Saberemos como criar matchers customizados do Hamcrest em Kotlin.
  • Seremos capazes de escolher a classe base ideal (FeatureMatcher vs TypeSafeDiagnosingMatcher) para cada tipo de verificação.
  • Saberemos como nomear nossos matchers e quando vale a pena criá-los.
Esta aula assume que você está familiarizado com o Sistema Sob Teste (SUT) (classe Task).

Criando Matchers Customizados do Hamcrest

Quando queremos criar um matcher customizado do Hamcrest, precisamos criar um método fábrica (factory method) que instancia e configura o nosso matcher. Podemos adicionar esse método fábrica diretamente dentro da nossa classe de teste ou criar uma classe Object Mother dedicada para abrigá-lo, seguindo duas regras simples:

  • Se o nosso matcher for utilizado por apenas uma classe de teste, devemos declarar esse método fábrica como private fun na própria classe de teste.
  • Se o nosso matcher for compartilhado por múltiplas classes de teste, devemos criar uma classe Object Mother (em Kotlin, um object) e adicionar nosso método fábrica nela.
Nota: Esta aula assume que nossos matchers customizados serão compartilhados por múltiplas classes de teste.

No Java clássico, uma classe Object Mother exigia criar uma classe public final com construtor private vazio para impedir instanciação. Em Kotlin, criamos um singleton nativo usando a palavra-chave object:

1
2
object TaskMatchers {
}
Convenção de Nomenclatura: Ao nomear um object que agrupa matchers customizados do Hamcrest, uma excelente convenção é adicionar o sufixo Matchers ao nome da classe de domínio inspecionada. Como nosso objeto cria matchers para inspecionar objetos Task, seu nome deve ser TaskMatchers.

Agora já temos onde colocar nossos métodos fábrica. Vamos avançar e criar primeiro um matcher customizado do Hamcrest que verifica se um único valor (propriedade) está correto.


Criando um Matcher Customizado do Hamcrest para um Único Valor (FeatureMatcher)

Se quisermos criar um matcher customizado do Hamcrest que verifica se uma única propriedade de um objeto está correta, devemos estender a classe abstrata org.hamcrest.FeatureMatcher<T, U>. Ao estender ou instanciar um FeatureMatcher<T, U>, fornecemos dois parâmetros de tipo genérico:

  • O parâmetro de tipo T especifica o tipo do objeto inspecionado (no nosso caso, Task).
  • O parâmetro de tipo U especifica o tipo da propriedade (feature) extraída do objeto (por exemplo, String? para o título).

Vamos criar um matcher customizado que verifica se um objeto Task possui o título (title) esperado seguindo três passos:

Primeiro, adicionamos um novo método fábrica estático (@JvmStatic fun) dentro de TaskMatchers. Esse método retorna um FeatureMatcher<Task, String?> e recebe o título esperado (title: String) como parâmetro:

1
2
3
4
5
6
7
8
9
import org.hamcrest.FeatureMatcher

object TaskMatchers {

    @JvmStatic
    fun hasTitle(title: String): FeatureMatcher<Task, String?> {
        TODO("Implementaremos no próximo passo")
    }
}

Segundo, modificamos nosso método fábrica para retornar uma instância anônima (object : FeatureMatcher<Task, String?>(...)) passando três argumentos ao construtor de FeatureMatcher:

  1. O matcher interno do Hamcrest usado para comparar o valor extraído com o valor esperado. Como queremos garantir que o título real seja igual ao título esperado, invocamos `is`(title) da classe Matchers.
  2. A descrição textual da propriedade inspecionada (exibida na linha Expected: da mensagem de falha). Passaremos a string "title".
  3. O nome da propriedade exibido na linha de divergência (but: / mismatch message) quando o matcher falha. Passaremos a string "title".

Depois de configurarmos o construtor do FeatureMatcher, o código-fonte de TaskMatchers fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import org.hamcrest.FeatureMatcher
import org.hamcrest.Matchers.`is`

object TaskMatchers {

    @JvmStatic
    fun hasTitle(title: String): FeatureMatcher<Task, String?> {
        return object : FeatureMatcher<Task, String?>(
            `is`(title),
            "title",
            "title"
        ) {
            // Ainda não compila — falta implementar featureValueOf
        }
    }
}

Terceiro, sobrescrevemos o método featureValueOf(actual: Task) da classe FeatureMatcher<T, U>. Esse método deve extrair e retornar o valor real da propriedade inspecionada no objeto Task — portanto, retornamos actual.title.

Depois de sobrescrevermos esse método, o código-fonte completo do nosso matcher em TaskMatchers fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import org.hamcrest.FeatureMatcher
import org.hamcrest.Matchers.`is`

object TaskMatchers {

    @JvmStatic
    fun hasTitle(title: String): FeatureMatcher<Task, String?> {
        return object : FeatureMatcher<Task, String?>(
            `is`(title),
            "title",
            "title"
        ) {
            override fun featureValueOf(actual: Task): String? {
                return actual.title
            }
        }
    }
}

Pronto! Agora podemos utilizar nosso matcher customizado hasTitle() exatamente da mesma maneira que usamos os matchers padrão do Hamcrest:

 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
import TaskMatchers.hasTitle
import org.hamcrest.MatcherAssert.assertThat
import org.junit.jupiter.api.Test

class TaskTest {

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

    @Test
    fun build_shouldCreateTaskWithCorrectTitle() {
        val task = Task.getBuilder()
            .withId(ID)
            .withCreator(CREATOR_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .build()

        assertThat(task, hasTitle(TITLE))
    }
}

Se o nosso teste falhar (por exemplo, se esperarmos "Write an example project1"), veremos a seguinte mensagem de erro formatada automaticamente pelo FeatureMatcher:

1
2
3
java.lang.AssertionError:
Expected: title is "Write an example project1"
     but: title was "Write an example project"

Vamos avançar e descobrir como criar um matcher customizado do Hamcrest que verifica múltiplos valores simultaneamente.


Criando um Matcher Customizado do Hamcrest para Múltiplos Valores (TypeSafeDiagnosingMatcher)

Quando precisamos criar um matcher customizado do Hamcrest que verifica se múltiplos campos combinados estão corretos (representando um estado de negócio), devemos utilizar a classe abstrata org.hamcrest.TypeSafeDiagnosingMatcher<T>. Ao estendê-la, fornecemos um parâmetro de tipo (T) que descreve o tipo do objeto inspecionado (Task).

Vamos criar um matcher customizado chamado isOpen() que verifica se um objeto Task está aberto. Em nosso domínio, uma tarefa aberta exige duas condições simultâneas: o status deve ser TaskStatus.OPEN e a resolution deve ser null. Criamos esse matcher em quatro passos:

Primeiro, adicionamos um novo método fábrica isOpen() em TaskMatchers que retorna um TypeSafeDiagnosingMatcher<Task> e não recebe parâmetros:

1
2
3
4
5
6
7
8
9
import org.hamcrest.TypeSafeDiagnosingMatcher

object TaskMatchers {

    @JvmStatic
    fun isOpen(): TypeSafeDiagnosingMatcher<Task> {
        TODO("Implementaremos nos próximos passos")
    }
}

Segundo, modificamos nosso método fábrica para retornar uma instância anônima de TypeSafeDiagnosingMatcher<Task>():

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import org.hamcrest.Description
import org.hamcrest.TypeSafeDiagnosingMatcher

object TaskMatchers {

    @JvmStatic
    fun isOpen(): TypeSafeDiagnosingMatcher<Task> {
        return object : TypeSafeDiagnosingMatcher<Task>() {
            // Ainda não compila
        }
    }
}

Terceiro, sobrescrevemos o método describeTo(description: Description). Esse método é responsável por construir a primeira metade da mensagem de erro (Expected:) exibida quando a asserção falha. Ela deve descrever claramente os valores esperados: status igual a TaskStatus.OPEN e resolution igual a null:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import org.hamcrest.Description
import org.hamcrest.TypeSafeDiagnosingMatcher

object TaskMatchers {

    @JvmStatic
    fun isOpen(): TypeSafeDiagnosingMatcher<Task> {
        return object : TypeSafeDiagnosingMatcher<Task>() {

            override fun describeTo(description: Description) {
                description.appendText("An open task should have the status:")
                    .appendValue(TaskStatus.OPEN)
                    .appendText(" and resolution:")
                    .appendValue(null)
            }
        }
    }
}

Quarto, sobrescrevemos o método matchesSafely(item: Task, mismatchDescription: Description): Boolean. Esse método recebe dois parâmetros:

  1. item: O objeto Task inspecionado (já verificado contra null e com tipo garantido pelo Hamcrest).
  2. mismatchDescription: O objeto Description usado para construir a segunda metade da mensagem de erro (but:) caso o matcher retorne false, descrevendo os valores reais encontrados no objeto.

Implementamos o método matchesSafely() em dois passos:

  1. Descrevemos os valores reais de item.status e item.resolution em mismatchDescription.
  2. Retornamos true se item.status == TaskStatus.OPEN && item.resolution == null, e false caso contrário.

Depois de implementarmos o método matchesSafely(), o código-fonte completo de TaskMatchers ficará 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
import org.hamcrest.Description
import org.hamcrest.TypeSafeDiagnosingMatcher

object TaskMatchers {

    @JvmStatic
    fun isOpen(): TypeSafeDiagnosingMatcher<Task> {
        return object : TypeSafeDiagnosingMatcher<Task>() {

            override fun matchesSafely(
                item: Task,
                mismatchDescription: Description
            ): Boolean {
                mismatchDescription.appendText(" but had status:")
                    .appendValue(item.status)
                    .appendText(" and resolution:")
                    .appendValue(item.resolution)

                return (item.status == TaskStatus.OPEN) &&
                    (item.resolution == null)
            }

            override fun describeTo(description: Description) {
                description.appendText("An open task should have the status:")
                    .appendValue(TaskStatus.OPEN)
                    .appendText(" and resolution:")
                    .appendValue(null)
            }
        }
    }
}

Concluímos! Agora podemos usar nosso matcher customizado isOpen() em nossos testes com uma leitura limpa e expressiva:

 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
import TaskMatchers.isOpen
import org.hamcrest.MatcherAssert.assertThat
import org.junit.jupiter.api.Test

class TaskTest {

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

    @Test
    fun build_shouldCreateAnOpenTask() {
        val task = Task.getBuilder()
            .withId(ID)
            .withCreator(CREATOR_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .build()

        assertThat(task, isOpen())
    }
}

Se o nosso teste falhar (por exemplo, se a tarefa estiver fechada como DONE), veremos imediatamente a seguinte mensagem de erro detalhada:

1
2
3
java.lang.AssertionError:
Expected: An open task should have the status: <OPEN> and resolution: null
     but:  but had status: <CLOSED> and resolution: <DONE>

Como Devemos Nomear Nossos Matchers Customizados do Hamcrest?

Devemos lembrar que o nome do nosso método fábrica é também o “nome” do matcher lido dentro do assertThat(...). As três perguntas a seguir ajudam a escolher o melhor nome para cada matcher:

  • A propriedade inspecionada contém apenas um dado informativo que é exibido ou editado? Se sim, considere iniciar o nome do método fábrica com o prefixo has (ex.: hasTitle, hasDescription).
  • A propriedade inspecionada registra um evento que aconteceu no passado? Se sim, considere iniciar o nome do método fábrica com o prefixo was (ex.: wasCreatedBy, wasClosedAsDuplicateBy).
  • A propriedade (ou conjunto de propriedades) identifica o estado atual do objeto? Se sim, considere iniciar o nome do método fábrica com o prefixo is (ex.: isOpen, isInProgress).

Lembre-se de que essas perguntas são diretrizes para estimular a legibilidade, não regras rígidas. Por exemplo, wasClosedAsDuplicateBy(closerId) representa um estado fechado da tarefa, mas lê-se muito melhor com was do que com is.


Quando Devemos Usar Matchers Customizados do Hamcrest?

Antes de criar qualquer matcher customizado, devemos sempre fazer esta pergunta:

Este matcher realmente tornará nosso código de teste melhor? Se sim, como?

Como matchers customizados adicionam código extra para manter na suíte de testes, só devemos criá-los quando os benefícios superarem o esforço. Vamos comparar nossos dois exemplos:

Primeiro, para verificar uma única propriedade simples (asserção centrada em dados / data-centric assertion):

1
2
3
4
5
// Matcher padrão do Hamcrest:
assertThat(task.title, `is`("title"))

// Matcher customizado do Hamcrest:
assertThat(task, hasTitle("title"))

Neste primeiro caso, o esforço extra não compensa. Escrever assertThat(task.title, is("title")) já é perfeitamente legível e não exige escrever 15 linhas de FeatureMatcher para cada campo de cada classe do projeto! Portanto, não devemos criar matchers customizados para simples verificações de um único campo.

Segundo, para verificar um estado de negócio composto por múltiplas propriedades:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Matchers padrão do Hamcrest:
assertThat(task.status, `is`(TaskStatus.OPEN))
assertThat(
    "An open task cannot have a resolution",
    task.resolution,
    nullValue()
)

// Matcher customizado de domínio do Hamcrest:
assertThat(task, isOpen())

Neste segundo caso, o esforço extra compensa enormemente por duas razões:

  • A asserção assertThat(task, isOpen()) é muito mais fácil e rápida de ler e revela a intenção de negócio imediatamente.
  • Nosso matcher customizado remove regras de negócio de dentro dos métodos de teste. Se amanhã a definição de “tarefa aberta” passar a exigir também closer == null, alteramos apenas o matcher isOpen() em TaskMatchers, sem precisar editar dezenas de testes espalhados pelo sistema!

Resumo

Esta aula nos ensinou quatro pontos essenciais:

  • Se precisarmos criar um matcher customizado do Hamcrest para um único valor, usamos a classe FeatureMatcher<T, U>.
  • Para criar um matcher customizado que valida múltiplos campos representando um estado de negócio, usamos a classe TypeSafeDiagnosingMatcher<T>.
  • Devemos escolher nomes expressivos (has..., is..., was...) para os métodos fábrica que criam nossos matchers.
  • Devemos criar matchers customizados especificamente para encapsular e remover regras de negócio dos nossos métodos de teste, evitando criá-los para getters simples.