Padrões de Teste em Kotlin
Padrões abrangentes de teste em Kotlin para escrever testes confiáveis e sustentáveis seguindo a metodologia TDD com Kotest e MockK.
Quando Usar
- Escrevendo novas funções ou classes em Kotlin
- Adicionando cobertura de testes a código Kotlin existente
- Implementando testes baseados em propriedades
- Seguindo o fluxo de trabalho TDD em projetos Kotlin
- Configurando Kover para cobertura de código
Como Funciona
- Identifique o código alvo — Encontre a função, classe ou módulo a ser testado
- Escreva uma spec Kotest — Escolha um estilo de spec (StringSpec, FunSpec, BehaviorSpec) que corresponda ao escopo do teste
- Simule dependências — Use MockK para isolar a unidade sob teste
- Execute os testes (VERMELHO) — Verifique se o teste falha com o erro esperado
- Implemente o código (VERDE) — Escreva o código mínimo para passar no teste
- Refatore — Melhore a implementação mantendo os testes verdes
- Verifique a cobertura — Execute
./gradlew koverHtmlReporte verifique cobertura acima de 80%
Exemplos
As seções a seguir contêm exemplos detalhados e executáveis para cada padrão de teste:
Referência Rápida
- Specs Kotest — Exemplos de StringSpec, FunSpec, BehaviorSpec, DescribeSpec em Estilos de Spec Kotest
- Mocking — Configuração do MockK, simulação de corrotinas, captura de argumentos em MockK
- Passo a passo TDD — Ciclo completo VERMELHO/VERDE/REFATORAR com EmailValidator em Fluxo de Trabalho TDD para Kotlin
- Cobertura — Configuração e comandos do Kover em Cobertura Kover
- Teste Ktor — Configuração do testApplication em Teste Ktor com testApplication
Fluxo de Trabalho TDD para Kotlin
O Ciclo VERMELHO-VERDE-REFATORAR
VERMELHO -> Escreva um teste que falha primeiro
VERDE -> Escreva o código mínimo para passar no teste
REFATORAR -> Melhore o código mantendo os testes verdes
REPITA -> Continue com o próximo requisito
Passo a Passo do TDD em Kotlin
// Passo 1: Defina a interface/assinatura
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result<String> {
TODO("not implemented")
}
// Passo 2: Escreva o teste que falha (VERMELHO)
// EmailValidatorTest.kt
package com.example.validator
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.result.shouldBeFailure
import io.kotest.matchers.result.shouldBeSuccess
class EmailValidatorTest : StringSpec({
"email válido retorna sucesso" {
validateEmail("[email protected]").shouldBeSuccess("[email protected]")
}
"email vazio retorna falha" {
validateEmail("").shouldBeFailure()
}
"email sem @ retorna falha" {
validateEmail("userexample.com").shouldBeFailure()
}
})
// Passo 3: Execute os testes - verifique a FALHA
// $ ./gradlew test
// EmailValidatorTest > valid email returns success FAILED
// kotlin.NotImplementedError: An operation is not implemented
// Passo 4: Implemente o código mínimo (VERDE)
fun validateEmail(email: String): Result<String> {
if (email.isBlank()) return Result.failure(IllegalArgumentException("Email não pode estar em branco"))
if ('@' !in email) return Result.failure(IllegalArgumentException("Email deve conter @"))
val regex = Regex("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$")
if (!regex.matches(email)) return Result.failure(IllegalArgumentException("Formato de email inválido"))
return Result.success(email)
}
// Passo 5: Execute os testes - verifique a APROVAÇÃO
// $ ./gradlew test
// EmailValidatorTest > valid email returns success PASSED
// EmailValidatorTest > empty email returns failure PASSED
// EmailValidatorTest > email without @ returns failure PASSED
// Passo 6: Refatore se necessário, verifique se os testes ainda passam
Estilos de Spec Kotest
StringSpec (Mais Simples)
class CalculatorTest : StringSpec({
"somar dois números positivos" {
Calculator.add(2, 3) shouldBe 5
}
"somar números negativos" {
Calculator.add(-1, -2) shouldBe -3
}
"somar zero" {
Calculator.add(0, 5) shouldBe 5
}
})
FunSpec (Estilo JUnit)
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser retorna usuário quando encontrado") {
val expected = User(id = "1", name = "Alice")
coEvery { repository.findById("1") } returns expected
val result = service.getUser("1")
result shouldBe expected
}
test("getUser lança exceção quando não encontrado") {
coEvery { repository.findById("999") } returns null
shouldThrow<UserNotFoundException> {
service.getUser("999")
}
}
})
BehaviorSpec (Estilo BDD)
class OrderServiceTest : BehaviorSpec({
val repository = mockk<OrderRepository>()
val paymentService = mockk<PaymentService>()
val service = OrderService(repository, paymentService)
Given("uma solicitação de pedido válida") {
val request = CreateOrderRequest(
userId = "user-1",
items = listOf(OrderItem("product-1", quantity = 2)),
)
When("o pedido é colocado") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(request)
Then("deve retornar um pedido confirmado") {
result.status shouldBe OrderStatus.CONFIRMED
}
Then("deve cobrar o pagamento") {
coVerify(exactly = 1) { paymentService.charge(any()) }
}
}
When("o pagamento falha") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
Then("deve lançar PaymentException") {
shouldThrow<PaymentException> {
service.placeOrder(request)
}
}
}
}
})
DescribeSpec (Estilo RSpec)
class UserValidatorTest : DescribeSpec({
describe("validateUser") {
val validator = UserValidator()
context("com entrada válida") {
it("aceita um usuário normal") {
val user = CreateUserRequest("Alice", "[email protected]")
validator.validate(user).shouldBeValid()
}
}
context("com nome inválido") {
it("rejeita nome em branco") {
val user = CreateUserRequest("", "[email protected]")
validator.validate(user).shouldBeInvalid()
}
it("rejeita nome excedendo o tamanho máximo") {
val user = CreateUserRequest("A".repeat(256), "[email protected]")
validator.validate(user).shouldBeInvalid()
}
}
}
})
Matchers Kotest
Matchers Principais
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
import io.kotest.matchers.string.*
import io.kotest.matchers.collections.*
import io.kotest.matchers.nulls.*
// Igualdade
result shouldBe expected
result shouldNotBe unexpected
// Strings
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// Coleções
list shouldContain "item"
list shouldHaveSize 3
list.shouldBeSorted()
list.shouldContainAll("a", "b", "c")
list.shouldBeEmpty()
// Nulos
result.shouldNotBeNull()
result.shouldBeNull()
// Tipos
result.shouldBeInstanceOf<User>()
// Números
count shouldBeGreaterThan 0
price shouldBeInRange 1.0..100.0
// Exceções
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "Idade deve ser positiva"
shouldNotThrow<Exception> {
validateAge(25)
}
Matchers Personalizados
fun beActiveUser() = object : Matcher<User> {
override fun test(value: User) = MatcherResult(
value.isActive && value.lastLogin != null,
{ "Usuário ${value.id} deve estar ativo com um último login" },
{ "Usuário ${value.id} não deve estar ativo" },
)
}
// Uso
user should beActiveUser()
MockK
Mocking Básico
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // Relaxado: retorna padrões
val service = UserService(repository, logger)
beforeTest {
clearMocks(repository, logger)
}
test("findUser delega para o repositório") {
val expected = User(id = "1", name = "Alice")
every { repository.findById("1") } returns expected
val result = service.findUser("1")
result shouldBe expected
verify(exactly = 1) { repository.findById("1") }
}
test("findUser retorna nulo para id desconhecido") {
every { repository.findById(any()) } returns null
val result = service.findUser("unknown")
result.shouldBeNull()
}
})
Simulação de Corrotinas
class AsyncUserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser função suspensa") {
coEvery { repository.findById("1") } returns User(id = "1", name = "Alice")
val result = service.getUser("1")
result.name shouldBe "Alice"
coVerify { repository.findById("1") }
}
test("getUser com atraso") {
coEvery { repository.findById("1") } coAnswers {
delay(100) // Simula trabalho assíncrono
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
Captura de Argumentos
test("save captura o argumento do usuário") {
val slot = slot<User>()
coEvery { repository.save(capture(slot)) } returns Unit
service.createUser(CreateUserRequest("Alice", "[email protected]"))
slot.captured.name shouldBe "Alice"
slot.captured.email shouldBe "[email protected]"
slot.captured.id.shouldNotBeNull()
}
Spy e Mocking Parcial
test("spy em objeto real") {
val realService = UserService(repository)
val spy = spyk(realService)
every { spy.generateId() } returns "fixed-id"
spy.createUser(request)
verify { spy.generateId() } // Sobrescrito
// Outros métodos usam a implementação real
}
Teste de Corrotinas
runTest para Funções Suspensas
import kotlinx.coroutines.test.runTest
class CoroutineServiceTest : FunSpec({
test("buscas concorrentes são concluídas juntas") {
runTest {
val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty()
result.products.shouldNotBeEmpty()
}
}
test("tempo limite após atraso") {
runTest {
val service = SlowService()
shouldThrow<TimeoutCancellationException> {
withTimeout(100) {
service.slowOperation() // Leva > 100ms
}
}
}
}
})
Teste de Fluxos (Flows)
import io.kotest.matchers.collections.shouldContainInOrder
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
class FlowServiceTest : FunSpec({
test("observeUsers emite atualizações") {
runTest {
val service = UserFlowService()
val emissions = service.observeUsers()
.take(3)
.toList()
emissions shouldHaveSize 3
emissions.last().shouldNotBeEmpty()
}
}
test("searchUsers aplica debounce na entrada") {
runTest {
val service = SearchService()
val queries = MutableSharedFlow<String>()
val results = mutableListOf<List<User>>()
val job = launch {
service.searchUsers(queries).collect { results.add(it) }
}
queries.emit("a")
queries.emit("ab")
queries.emit("abc") // Apenas este deve acionar a pesquisa
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
class DispatcherTest : FunSpec({
test("usa dispatcher de teste para execução controlada") {
val dispatcher = StandardTestDispatcher()
runTest(dispatcher) {
var completed = false
launch {
delay(1000)
completed = true
}
completed shouldBe false
advanceTimeBy(1000)
completed shouldBe true
}
}
})
Testes Baseados em Propriedades
Teste de Propriedades Kotest
import io.kotest.core.spec.style.FunSpec
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
import io.kotest.property.forAll
import io.kotest.property.checkAll
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import kotlinx.serialization.decodeFromString
// Nota: O teste de ida e volta (roundtrip) de serialização abaixo requer que a classe de dados User
// seja anotada com @Serializable (do kotlinx.serialization).
class PropertyTest : FunSpec({
test("inversão de string é involutiva") {
forAll<String> { s ->
s.reversed().reversed() == s
}
}
test("ordenação de lista é idempotente") {
forAll(Arb.list(Arb.int())) { list ->
list.sorted() == list.sorted().sorted()
}
}
test("ida e volta de serialização preserva dados") {
checkAll(Arb.bind(Arb.string(1..50), Arb.string(5..100)) { name, email ->
User(name = name, email = "$email@test.com")
}) { user ->
val json = Json.encodeToString(user)
val decoded = Json.decodeFromString<User>(json)
decoded shouldBe user
}
}
})
Geradores Personalizados
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
class CustomGeneratorsTest : FunSpec({
test("gerar usuários válidos") {
val users = Arb.bind(
Arb.string(1..50),
Arb.string(5..100),
Arb.int(18..120),
Arb.email()
) { name, address, age, email ->
User(name, address, age, email)
}
checkAll(users, 1000) { user ->
user.age shouldBeGreaterThanOrEqual 18
user.email shouldContain "@"
}
}
})
Cobertura Kover
Configuração do Kover
// build.gradle.kts
plugins {
id("org.jetbrains.kotlin.jvm") version "1.9.22"
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22"
id("org.jetbrains.kotlinx.kover") version "0.7.5"
}
koverReport {
defaults {
html {
onCheck = true
}
verify {
onCheck = true
rule {
minBound(80)
maxBound(100)
}
}
}
filters {
excludes {
classes(
"*Test*",
"*Application*",
"*Config*"
)
}
}
}
Comandos do Kover
# Gerar relatório HTML
./gradlew koverHtmlReport
# Executar verificação
./gradlew koverVerify
# Relatório XML (para integração com CI)
./gradlew koverXmlReport
# Limpar dados de cobertura
./gradlew koverClean
# Executar testes com cobertura
./gradlew check
Verificação com Gatilho de Cobertura
// build.gradle.kts - Aplicar regras
koverReport {
defaults {
verify {
rule {
minBound(80)
maxBound(100)
entity = KotlinProjectCoverage // para todo o projeto
// ou use KotlinModuleCoverage para módulos específicos
}
}
}
}
// Falha no build se a cobertura cair abaixo de 80%
// $ ./gradlew koverVerify
// ! Violation: A regra de cobertura mínima (80%) foi violada: linha coberta 75%
Ignorar Cobertura
@Suppress("unused")
@Generated
class GeneratedCode {
// Kover ignora classes anotadas com @Generated
}
// Ou em build.gradle.kts
koverReport {
filters {
excludes {
classes(
"com.example.generated.*",
"*Test*"
)
}
}
}
Teste Ktor com testApplication
Configuração do Teste
dependencies {
testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion")
}
class UserRouteTest : FunSpec({
test("POST /users cria um usuário") {
testApplication {
application {
module()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("Alice", "[email protected]"))
}
response.status shouldBe HttpStatusCode.Created
val user = response.body<User>()
user.name shouldBe "Alice"
}
}
test("GET /users retorna lista") {
testApplication {
application {
module()
}
val response = client.get("/users")
response.status shouldBe HttpStatusCode.OK
}
}
})
Melhores Práticas
Padrões de Nomenclatura
// Testes de Função
class EmailValidatorTest
class CalculatorServiceTest
// Testes de Classe
class UserRepositoryTest
class OrderProcessingServiceTest
// Testes de Integração
class UserRegistrationIntegrationTest
class PaymentFlowIntegrationTest
Organização de Testes
src/
├── main/kotlin/
│ └── com/example/
│ ├── Model.kt
│ ├── Service.kt
│ └── Repository.kt
└── test/kotlin/
└── com/example/
├── ModelTest.kt
├── ServiceTest.kt
├── RepositoryTest.kt
└── integration/
└── UserFlowIntegrationTest.kt
Padrão Triple A (Arrange-Act-Assert)
test("criar usuário com endereço") {
// Arrange - Organizar
val repository = mockk<UserRepository>()
val service = UserService(repository)
val request = CreateUserRequest("Alice", "[email protected]")
val expected = User(id = "1", name = "Alice", email = "[email protected]")
coEvery { repository.save(any()) } returns expected
// Act - Agir
val result = service.createUser(request)
// Assert - Afirmar
result.name shouldBe "Alice"
result.email shouldBe "[email protected]"
coVerify(exactly = 1) { repository.save(any()) }
}
Testar Diferentes Cenários
class OrderServiceComprehensiveTest : FunSpec({
lateinit var repository: OrderRepository
lateinit var paymentService: PaymentService
lateinit var service: OrderService
beforeTest {
repository = mockk()
paymentService = mockk()
service = OrderService(repository, paymentService)
}
context("colocar pedido") {
test("com estoque suficiente deve ser bem-sucedido") {
coEvery { repository.checkStock(any()) } returns true
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(validRequest)
result.status shouldBe OrderStatus.CONFIRMED
}
test("com estoque insuficiente deve falhar") {
coEvery { repository.checkStock(any()) } returns false
shouldThrow<InsufficientStockException> {
service.placeOrder(validRequest)
}
}
test("com pagamento recusado deve lançar exceção") {
coEvery { repository.checkStock(any()) } returns true
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
shouldThrow<PaymentException> {
service.placeOrder(validRequest)
}
}
test("com dados inválidos deve lançar exceção de validação") {
val invalidRequest = CreateOrderRequest(userId = "", items = emptyList())
shouldThrow<ValidationException> {
service.placeOrder(invalidRequest)
}
}
}
})
Resumo de Comandos
| Comando | Descrição |
|---|---|
./gradlew test | Executar todos os testes |
./gradlew test --tests *EmailValidatorTest* | Executar testes específicos |
./gradlew test --tests *EmailValidatorTest.*valid email* | Executar teste por nome |
./gradlew koverHtmlReport | Gerar relatório de cobertura HTML |
./gradlew koverVerify | Executar verificação de regras de cobertura |
./gradlew koverClean | Limpar dados de cobertura |
./gradlew check | Executar testes com verificação de cobertura |
Solução de Problemas
- Teste não encontrado: Verifique se a classe de teste termina com "Test" no nome do arquivo
- MockKException: Certifique-se de que
clearMocksé chamado embeforeTest - Falha de cobertura: Execute
koverHtmlReporte verifique o relatório para áreas descobertas - Falha em teste de corrotina: Use
runTestcomStandardTestDispatcherem vez deDispatchers.Default - Problemas de serialização: Verifique se a classe de dados tem anotação
@Serializable(de kotlinx.serialization)
Este documento foi traduzido do original em inglês.