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.