Asserções Expressivas com Hamcrest e Kotlin Matchers

Ao concluirmos esta aula, nós:

  • Seremos capazes de escrever asserções expressivas com JUnit Jupiter 6 e Hamcrest em Kotlin.
  • Compreenderemos quando devemos utilizar Hamcrest (especialmente ao testar controladores Spring MVC com MockMvc).

Configurando as Dependências Necessárias

Antes de podermos escrever asserções com o Hamcrest, precisamos garantir que a biblioteca esteja disponível no classpath de teste. No ecossistema moderno (Hamcrest 2.x / 3.x), o artefato unificado é o org.hamcrest:hamcrest (que inclusive já vem incluído transitivamente no spring-boot-starter-test).

Se estivermos usando Maven em um projeto isolado, declaramos a dependência no arquivo pom.xml:

1
2
3
4
5
6
<dependency>
    <groupId>org.hamcrest</groupId>
    <artifactId>hamcrest</artifactId>
    <version>3.0</version>
    <scope>test</scope>
</dependency>

No Gradle 9 (Kotlin DSL), declaramos a dependência dentro da nossa suíte de testes no arquivo build.gradle.kts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
testing {
    suites {
        val test by getting(JvmTestSuite::class) {
            useJUnitJupiter("6.0.3")
            dependencies {
                implementation("org.hamcrest:hamcrest:3.0")
            }
        }
    }
}

Depois de adicionarmos essa dependência ao nosso script de build, podemos começar a escrever asserções com o Hamcrest.


Escrevendo Asserções com Hamcrest

Quando queremos escrever asserções com o Hamcrest no JUnit Jupiter 6, utilizamos o método estático assertThat() da classe org.hamcrest.MatcherAssert (que substituiu o antigo org.junit.Assert.assertThat depreciado no JUnit 4).

Esse método recebe até três parâmetros na seguinte ordem:

  1. Um parâmetro opcional (String) contendo a mensagem de erro customizada que será exibida caso a asserção falhe.
  2. O valor ou objeto real obtido (actual).
  3. O matcher do Hamcrest (Matcher<T>) que especifica a condição ou valor esperado (expected).
Nota sobre a Palavra Reservada is no Kotlin:
Em Kotlin, is é uma palavra-chave reservada da linguagem (usada para checagem de tipos, equivalente ao instanceof do Java). Por isso, ao usar o matcher Matchers.is(...) do Hamcrest em Kotlin, você pode:
1. Envolver o método entre crases: `is`(expected);
2. Usar seu método equivalente no Hamcrest: equalTo(expected);
3. Ou usar um import alias do Kotlin no topo do arquivo: import org.hamcrest.Matchers.`is` as isEqualTo.

Vamos avançar e examinar exemplos práticos que demonstram como escrever diferentes tipos de asserções com Hamcrest em Kotlin.

Asserções para Valores Booleanos

Se quisermos verificar que um valor Boolean é true, criamos o matcher apropriado invocando o método `is`(true) da classe Matchers. Em outras palavras, podemos usar uma destas duas asserções:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.Test

class BooleanAssertionTest {

    companion object {
        private const val TRUE = true
    }

    @Test
    fun booleanShouldBeTrue() {
        assertThat(TRUE, `is`(true))
        assertThat(
            "Expected boolean to be true but was false",
            TRUE,
            `is`(true)
        )
    }
}

Se quisermos verificar que um valor Boolean é false, invocamos `is`(false) da classe Matchers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.Test

class BooleanAssertionTest {

    companion object {
        private const val FALSE = false
    }

    @Test
    fun booleanShouldBeFalse() {
        assertThat(FALSE, `is`(false))
        assertThat(
            "Expected boolean to be false but was true",
            FALSE,
            `is`(false)
        )
    }
}

Asserções para Verificar se um Objeto É null ou Não É null

Se quisermos verificar que um objeto é null, criamos o matcher do Hamcrest invocando o método nullValue() da classe Matchers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.Test

class ObjectAssertionTest {

    @Test
    fun objectShouldBeNull() {
        val actual: Any? = null

        assertThat(actual, nullValue())
        assertThat(
            "Expected object to be null but was: $actual",
            actual,
            nullValue()
        )
    }
}

Se quisermos verificar que um objeto não é null, criamos o matcher invocando o método notNullValue() da classe Matchers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.notNullValue
import org.junit.jupiter.api.Test

class ObjectAssertionTest {

    @Test
    fun objectShouldNotBeNull() {
        val actual: Any? = Any()

        assertThat(actual, notNullValue())
        assertThat(
            "Expected object to be not null but was null",
            actual,
            notNullValue()
        )
    }
}

Asserções de Igualdade entre Dois Valores ou Objetos

Se quisermos verificar que o valor (ou objeto) real obtido é igual ao valor (ou objeto) esperado, invocamos o método `is`() (ou equalTo()) da classe Matchers. Por exemplo, para comparar dois números inteiros (Int), usamos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.Test

class ObjectAssertionTest {

    @Test
    fun twoIntegersShouldBeEqual() {
        val actual = 9
        val expected = 9

        assertThat(actual, `is`(expected))
        assertThat(
            "Expected integer to be: $expected but was: $actual",
            actual,
            `is`(expected)
        )
    }
}

Se quisermos verificar que o valor real obtido não é igual ao valor esperado, invocamos o método not() da classe Matchers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.not
import org.junit.jupiter.api.Test

class ObjectAssertionTest {

    @Test
    fun twoIntegersShouldNotBeEqual() {
        val actual = 9
        val expected = 41

        assertThat(actual, not(expected))
        assertThat(
            "Expected integer to not be: $expected but was: $actual",
            actual,
            not(expected)
        )
    }
}

Asserções de Referência de Objetos

Se quisermos verificar que duas variáveis referenciam exatamente a mesma instância de objeto na memória, criamos o matcher invocando o método sameInstance() da classe Matchers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.sameInstance
import org.junit.jupiter.api.Test

class ObjectAssertionTest {

    @Test
    fun shouldPointToSameObject() {
        val actual = Any()
        val expected = actual

        assertThat(actual, sameInstance(expected))
        assertThat(
            "Expected two objects to be the same but they are not",
            actual,
            sameInstance(expected)
        )
    }
}
Nota: Para verificar que dois objetos não apontam para a mesma instância no Hamcrest, você pode combinar os matchers not(sameInstance(expected)).

Asserções de Igualdade entre Dois Arrays

Se quisermos verificar que dois arrays são iguais, podemos invocar o método `is`() da classe Matchers. Por exemplo, para verificar que dois IntArray possuem os mesmos elementos:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.`is`
import org.junit.jupiter.api.Test

class ArrayAssertionTest {

    @Test
    fun twoIntArraysShouldBeEqual() {
        val actual = intArrayOf(2, 5, 7)
        val expected = intArrayOf(2, 5, 7)

        assertThat(actual, `is`(expected))
        assertThat(
            "Expected array: ${expected.contentToString()} but got array: ${actual.contentToString()}",
            actual,
            `is`(expected)
        )
    }
}

Asserções para Listas (List)

Se quisermos escrever uma asserção que verifica se o tamanho (size) de uma lista está correto, criamos o matcher invocando o método hasSize() da classe Matchers. Por exemplo, para verificar que o tamanho de uma lista vazia é 0:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.hasSize
import org.junit.jupiter.api.Test

class ListAssertionTest {

    @Test
    fun sizeOfEmptyListShouldBeZero() {
        val emptyList = emptyList<String>()

        assertThat(emptyList, hasSize(0))
        assertThat(
            "Expected size to be: 0 but was: ${emptyList.size}",
            emptyList,
            hasSize(0)
        )
    }
}

Se quisermos escrever uma asserção que verifica se uma lista contém os objetos esperados, escolhemos um destes dois matchers do Hamcrest:

  • Para verificar que a lista contém os objetos esperados na ordem exata especificada, invocamos o método contains() da classe Matchers.
  • Para verificar que a lista contém os objetos esperados em qualquer ordem, invocamos o método containsInAnyOrder() da classe Matchers.

A classe de teste que valida ambas as situações 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
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.contains
import org.hamcrest.Matchers.containsInAnyOrder
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class ListAssertionTest {

    private lateinit var first: Any
    private lateinit var second: Any
    private lateinit var list: List<Any>

    @BeforeEach
    fun createAndInitializeList() {
        first = Any()
        second = Any()
        list = listOf(first, second)
    }

    @Test
    fun listShouldContainCorrectObjectsInCorrectOrder() {
        assertThat(list, contains(first, second))
    }

    @Test
    fun listShouldContainCorrectObjectsInAnyOrder() {
        assertThat(list, containsInAnyOrder(second, first))
    }
}

Asserções para Mapas (Map)

Se quisermos verificar que um mapa contém uma chave específica, criamos o matcher invocando o método hasKey() da classe Matchers:

 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 org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.hasKey
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class MapAssertionTest {

    companion object {
        private const val KEY = "key"
    }

    private lateinit var expected: Any
    private lateinit var map: MutableMap<String, Any>

    @BeforeEach
    fun createAndInitializeMap() {
        map = mutableMapOf()
        expected = Any()
        map[KEY] = expected
    }

    @Test
    fun shouldContainCorrectKey() {
        assertThat(map, hasKey(KEY))
    }
}

Se quisermos verificar que um mapa contém um par chave-valor específico (entry), invocamos o método hasEntry() da classe Matchers:

 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 org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.hasEntry
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test

class MapAssertionTest {

    companion object {
        private const val KEY = "key"
    }

    private lateinit var expected: Any
    private lateinit var map: MutableMap<String, Any>

    @BeforeEach
    fun createAndInitializeMap() {
        map = mutableMapOf()
        expected = Any()
        map[KEY] = expected
    }

    @Test
    fun shouldContainObjectWithCorrectKey() {
        assertThat(map, hasEntry(KEY, expected))
    }
}

Combinando Múltiplas Asserções do Hamcrest

Esta seção assume que você está familiarizado com o Sistema Sob Teste (SUT) (classe Task).

Quando queremos construir uma asserção composta por múltiplos matchers do Hamcrest, podemos escolher uma destas duas opções:

  • Se quisermos garantir que todas as condições sejam satisfeitas simultaneamente (operador lógico AND), invocamos o método allOf() da classe Matchers.
  • Se quisermos garantir que pelo menos uma das condições seja satisfeita (operador lógico OR), invocamos o método anyOf() da classe Matchers.

Os exemplos a seguir demonstram como combinar múltiplos matchers do Hamcrest ao escrever asserções para um objeto Task. Esses exemplos utilizam o matcher hasProperty(), que recebe dois parâmetros:

  1. O nome da propriedade (que possui um getter JavaBeans correspondente, como getId(), getTitle()).
  2. O matcher do Hamcrest que inspecionará o valor daquela propriedade.

Se quisermos verificar que todos os valores de propriedades do objeto Task criado estão corretos, escrevemos o seguinte método de teste:

 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
46
47
48
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.hasProperty
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.BeforeEach
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"
    }

    private lateinit var task: Task

    @BeforeEach
    fun createTask() {
        task = Task.getBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .build()
    }

    @Test
    fun build_shouldCreateTaskWithCorrectFieldValues() {
        assertThat(
            task,
            allOf(
                hasProperty("id", `is`(ID)),
                hasProperty("assignee", hasProperty("userId", `is`(ASSIGNEE_ID))),
                hasProperty("closer", nullValue()),
                hasProperty("creator", hasProperty("userId", `is`(CREATOR_ID))),
                hasProperty("title", `is`(TITLE)),
                hasProperty("description", `is`(DESCRIPTION)),
                hasProperty("status", `is`(TaskStatus.OPEN)),
                hasProperty("resolution", nullValue())
            )
        )
    }
}

Por outro lado, se quisermos verificar que pelo menos um dos campos do objeto Task corresponde às condições listadas, utilizamos anyOf():

 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
46
47
48
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.anyOf
import org.hamcrest.Matchers.hasProperty
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.junit.jupiter.api.BeforeEach
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"
    }

    private lateinit var task: Task

    @BeforeEach
    fun createTask() {
        task = Task.getBuilder()
            .withId(ID)
            .withAssignee(ASSIGNEE_ID)
            .withCreator(CREATOR_ID)
            .withTitle(TITLE)
            .withDescription(DESCRIPTION)
            .build()
    }

    @Test
    fun build_shouldCreateTaskWithAtLeastOneCorrectFieldValue() {
        assertThat(
            task,
            anyOf(
                hasProperty("id", `is`(ID)),
                hasProperty("assignee", hasProperty("userId", `is`(ASSIGNEE_ID))),
                hasProperty("closer", nullValue()),
                hasProperty("creator", hasProperty("userId", `is`(CREATOR_ID))),
                hasProperty("title", `is`(TITLE)),
                hasProperty("description", `is`(DESCRIPTION)),
                hasProperty("status", `is`(TaskStatus.OPEN)),
                hasProperty("resolution", nullValue())
            )
        )
    }
}

Agora que sabemos como escrever diversas asserções utilizando matchers do Hamcrest, vamos analisar quando devemos aplicar essa técnica.


Quando Devemos Usar o Hamcrest?

Os exemplos acima demonstram que escrever asserções com o Hamcrest é uma escolha muito superior às asserções padrão do JUnit (assertEquals) por três razões:

  • Passamos os valores na ordem natural de raciocínio: primeiro o valor obtido (actual), depois o matcher esperado (expected).
  • Os matchers do Hamcrest fazem com que as asserções sejam lidas quase como uma frase em inglês natural (assertThat(actual, is(expected))), facilitando a leitura do teste.
  • O método assertThat(T actual, Matcher<? super T> matcher) protege o desenvolvedor contra inversões acidentais: como o segundo parâmetro exige um tipo Matcher, é impossível inverter actual e expected sem que o compilador aponte erro imediatamente!

Em testes unitários puros de domínio em Kotlin, costumamos preferir o AssertJ (que veremos a partir da aula 04 deste tópico) ou Kotest Matchers porque o autocompletar fluente (.) na IDE é ainda mais produtivo do que importar dezenas de métodos estáticos da classe Matchers.

No entanto, conhecer bem o Hamcrest é indispensável no ecossistema Spring: quando escrevemos testes para controladores web e APIs REST utilizando o MockMvc (Spring MVC Test framework), os métodos de verificação de JSON (jsonPath("$.title", is("..."))), modelo (model().attribute(...)) e conteúdo utilizam matchers do Hamcrest nativamente!

Vamos resumir o que aprendemos nesta aula.


Resumo

Esta aula nos ensinou quatro pontos principais:

  • Para escrever asserções com Hamcrest no JUnit 6, utilizamos o método MatcherAssert.assertThat().
  • Para combinar múltiplos matchers do Hamcrest em uma única verificação, usamos os métodos allOf() (E lógico) ou anyOf() (OU lógico) da classe Matchers.
  • Escrever asserções com Hamcrest é muito mais seguro e legível do que usar assertEquals(expected, actual) da API básica do JUnit.
  • Os matchers do Hamcrest são amplamente utilizados em conjunto com o MockMvc ao testar endpoints web e JSON no Spring MVC Test Framework.