Testes unitários são a base de qualquer projeto profissional em Kotlin. Neste tutorial, vamos aprender a escrever testes eficazes usando JUnit 5 e MockK, desde a configuração inicial até técnicas avançadas como testes parametrizados e testes de coroutines. Se você quer entregar código com confiança, este guia é para você.

Por que Testar?

Testes unitários verificam se unidades individuais do seu código (funções, classes, métodos) funcionam corretamente de forma isolada. Eles oferecem feedback rápido durante o desenvolvimento, documentam o comportamento esperado do sistema e protegem contra regressões quando você refatora ou adiciona novas features. Em Kotlin, a expressividade da linguagem torna os testes especialmente legíveis e concisos.

Passo 1: Configuração do Projeto com JUnit 5

Primeiro, configure as dependências no build.gradle.kts:

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.0"
}

dependencies {
    testImplementation(kotlin("test"))
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.3")
    testImplementation("io.mockk:mockk:1.13.12")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.1")
}

tasks.test {
    useJUnitPlatform()
}

Com kotlin("test"), o Kotlin já inclui as assertions básicas. O JUnit 5 serve como engine de execução, e o MockK é a biblioteca de mocking idiomática para Kotlin.

Passo 2: Escrevendo seu Primeiro Teste

Vamos começar com uma classe simples e seus testes correspondentes:

// src/main/kotlin/Calculadora.kt
class Calculadora {
    fun somar(a: Int, b: Int): Int = a + b
    fun dividir(a: Double, b: Double): Double {
        require(b != 0.0) { "Divisor não pode ser zero" }
        return a / b
    }
    fun ehPar(numero: Int): Boolean = numero % 2 == 0
}

Agora, o teste:

// src/test/kotlin/CalculadoraTest.kt
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class CalculadoraTest {
    private val calc = Calculadora()

    @Test
    fun `deve somar dois numeros corretamente`() {
        assertEquals(5, calc.somar(2, 3))
        assertEquals(0, calc.somar(-1, 1))
        assertEquals(-4, calc.somar(-2, -2))
    }

    @Test
    fun `deve dividir dois numeros`() {
        assertEquals(2.5, calc.dividir(5.0, 2.0))
    }

    @Test
    fun `deve lancar excecao ao dividir por zero`() {
        assertThrows<IllegalArgumentException> {
            calc.dividir(10.0, 0.0)
        }
    }

    @Test
    fun `deve verificar se numero eh par`() {
        assertTrue(calc.ehPar(4))
        assertFalse(calc.ehPar(7))
    }
}

Note que Kotlin permite nomes de testes com espaços usando backticks, o que melhora enormemente a legibilidade dos relatórios de testes. Adote essa prática para descrever o comportamento esperado de forma clara.

Passo 3: Ciclo de Vida dos Testes

O JUnit 5 oferece annotations para controlar o ciclo de vida dos testes. Use @BeforeEach para configurar estado antes de cada teste e @AfterEach para limpeza:

import org.junit.jupiter.api.*

class RepositorioUsuariosTest {
    private lateinit var repositorio: RepositorioUsuarios

    @BeforeEach
    fun configurar() {
        repositorio = RepositorioUsuarios()
        repositorio.adicionar(Usuario(1, "Ana", "ana@email.com"))
    }

    @AfterEach
    fun limpar() {
        repositorio.limparTodos()
    }

    @Test
    fun `deve encontrar usuario por id`() {
        val usuario = repositorio.buscarPorId(1)
        assertEquals("Ana", usuario?.nome)
    }

    @Test
    fun `deve retornar null para id inexistente`() {
        val usuario = repositorio.buscarPorId(999)
        assertNull(usuario)
    }

    companion object {
        @JvmStatic
        @BeforeAll
        fun inicializacao() {
            println("Executado uma vez antes de todos os testes")
        }
    }
}

O @BeforeAll é útil para inicializações pesadas como conexão com banco de dados de teste. Note o uso de companion object com @JvmStatic, exigido pelo JUnit 5 para métodos estáticos.

Passo 4: Mocking com MockK

O MockK é a biblioteca de mocking mais popular para Kotlin. Diferente do Mockito, ele foi criado especificamente para Kotlin e suporta nativamente coroutines, extension functions e data classes.

import io.mockk.*
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals

interface ServicoEmail {
    fun enviar(destinatario: String, assunto: String, corpo: String): Boolean
}

class ServicoNotificacao(private val email: ServicoEmail) {
    fun notificarUsuario(usuario: Usuario, mensagem: String): Boolean {
        return email.enviar(usuario.email, "Notificação", mensagem)
    }
}

class ServicoNotificacaoTest {
    private val emailMock = mockk<ServicoEmail>()
    private val servico = ServicoNotificacao(emailMock)

    @Test
    fun `deve enviar email para o usuario`() {
        every { emailMock.enviar(any(), any(), any()) } returns true

        val resultado = servico.notificarUsuario(
            Usuario(1, "Carlos", "carlos@email.com"),
            "Sua conta foi ativada"
        )

        assertTrue(resultado)
        verify {
            emailMock.enviar("carlos@email.com", "Notificação", "Sua conta foi ativada")
        }
    }

    @Test
    fun `deve retornar false quando envio falhar`() {
        every { emailMock.enviar(any(), any(), any()) } returns false

        val resultado = servico.notificarUsuario(
            Usuario(1, "Ana", "ana@email.com"),
            "Teste"
        )

        assertFalse(resultado)
    }
}

O every { ... } returns ... define o comportamento do mock, e verify { ... } confirma que o método foi chamado com os argumentos corretos. O any() é um matcher que aceita qualquer valor.

Passo 5: Testando Coroutines com runTest

Para testar funções suspend, use o runTest do módulo kotlinx-coroutines-test. Ele controla o tempo virtual, permitindo testar delays sem esperar o tempo real.

import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.delay
import io.mockk.coEvery
import io.mockk.coVerify

interface RepositorioRemoto {
    suspend fun buscarDados(id: String): String
}

class CasoDeUso(private val repositorio: RepositorioRemoto) {
    suspend fun executar(id: String): Result<String> {
        return try {
            val dados = repositorio.buscarDados(id)
            Result.success(dados)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

class CasoDeUsoTest {
    private val repoMock = mockk<RepositorioRemoto>()
    private val casoDeUso = CasoDeUso(repoMock)

    @Test
    fun `deve retornar sucesso quando repositorio responde`() = runTest {
        coEvery { repoMock.buscarDados("123") } returns "dados-do-servidor"

        val resultado = casoDeUso.executar("123")

        assertTrue(resultado.isSuccess)
        assertEquals("dados-do-servidor", resultado.getOrNull())
        coVerify { repoMock.buscarDados("123") }
    }

    @Test
    fun `deve retornar falha quando repositorio lanca excecao`() = runTest {
        coEvery { repoMock.buscarDados(any()) } throws RuntimeException("Erro de rede")

        val resultado = casoDeUso.executar("456")

        assertTrue(resultado.isFailure)
    }
}

Note o uso de coEvery e coVerify (prefixo co) para funções suspend. O runTest avança automaticamente o tempo virtual quando encontra chamadas a delay, então seus testes executam instantaneamente.

Passo 6: Testes Parametrizados

Testes parametrizados permitem executar o mesmo teste com diferentes entradas, reduzindo duplicação:

import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.CsvSource
import org.junit.jupiter.params.provider.MethodSource
import java.util.stream.Stream

class ValidadorTest {
    @ParameterizedTest
    @CsvSource(
        "teste@email.com, true",
        "invalido, false",
        "user@domain.org, true",
        "'', false",
        "sem-arroba.com, false"
    )
    fun `deve validar formato de email`(email: String, esperado: Boolean) {
        val validador = ValidadorEmail()
        assertEquals(esperado, validador.ehValido(email))
    }

    @ParameterizedTest
    @MethodSource("fornecerSenhas")
    fun `deve validar forca da senha`(caso: CasoSenha) {
        val validador = ValidadorSenha()
        assertEquals(caso.esperado, validador.ehForte(caso.senha))
    }

    companion object {
        @JvmStatic
        fun fornecerSenhas(): Stream<CasoSenha> = Stream.of(
            CasoSenha("123", false),
            CasoSenha("Abc@1234", true),
            CasoSenha("senhafraca", false),
            CasoSenha("F0rte!Senh@", true)
        )
    }

    data class CasoSenha(val senha: String, val esperado: Boolean)
}

O @CsvSource é prático para dados simples. Para casos mais complexos, use @MethodSource com um método que retorna um Stream de objetos.

Boas Práticas para Código Testável

Escrever testes eficientes começa com código bem estruturado. Aplique injeção de dependências — receba dependências via construtor ao invés de criá-las internamente. Use interfaces para definir contratos que podem ser facilmente mockados. Mantenha funções pequenas e com responsabilidade única. Evite estado global e efeitos colaterais ocultos.

Siga o padrão AAA (Arrange, Act, Assert): configure o cenário, execute a ação e verifique o resultado. Cada teste deve ser independente e não depender da ordem de execução.

Erros Comuns

1. Testes que dependem uns dos outros: Cada teste deve configurar seu próprio estado. Nunca assuma que um teste rodou antes de outro. Use @BeforeEach para garantir estado limpo.

2. Testar implementação ao invés de comportamento: Evite verificar detalhes internos como quantas vezes um método privado foi chamado. Teste o resultado observável — a saída da função, o estado final do objeto.

3. Mocks excessivos: Se você precisa de muitos mocks em um teste, provavelmente o código tem acoplamento alto. Refatore para reduzir dependências antes de adicionar mais mocks.

4. Não testar cenários de erro: Teste não apenas o caminho feliz. Verifique exceções, valores nulos, listas vazias e entradas inválidas.

5. Esquecer coEvery para suspend functions: Usar every ao invés de coEvery com funções suspend causa erros confusos em tempo de execução. Sempre use a variante co para coroutines.

Conclusão e Próximos Passos

Neste tutorial, cobrimos o ecossistema completo de testes unitários em Kotlin: JUnit 5 para estrutura e asserções, MockK para mocking idiomático, runTest para coroutines e testes parametrizados para reduzir duplicação. Com essas ferramentas, você tem tudo que precisa para criar uma suíte de testes robusta e confiável.

Como próximos passos, explore testes de integração com TestContainers para bancos de dados, testes de API com o módulo de testes do Ktor, e ferramentas de cobertura de código como JaCoCo. Confira também nosso tutorial sobre Coroutines Avançadas para entender melhor o runTest e padrões avançados de concorrência testável.