Patrones de Pruebas en Kotlin
Patrones completos de pruebas en Kotlin para escribir pruebas confiables y mantenibles siguiendo la metodología TDD con Kotest y MockK.
Cuándo Usarlo
- Al escribir nuevas funciones o clases en Kotlin
- Al agregar cobertura de pruebas a código Kotlin existente
- Al implementar pruebas basadas en propiedades
- Al seguir el flujo de trabajo TDD en proyectos Kotlin
- Al configurar Kover para cobertura de código
Cómo Funciona
- Identificar el código objetivo — Encuentra la función, clase o módulo a probar
- Escribir un spec de Kotest — Elige un estilo de spec (StringSpec, FunSpec, BehaviorSpec) que coincida con el alcance de la prueba
- Mockear dependencias — Usa MockK para aislar la unidad bajo prueba
- Ejecutar pruebas (RED) — Verifica que la prueba falle con el error esperado
- Implementar código (GREEN) — Escribe el código mínimo para que la prueba pase
- Refactorizar — Mejora la implementación mientras las pruebas sigan en verde
- Verificar cobertura — Ejecuta
./gradlew koverHtmlReporty verifica una cobertura del 80% o más
Ejemplos
Las siguientes secciones contienen ejemplos detallados y ejecutables para cada patrón de prueba:
Referencia Rápida
- Specs de Kotest — Ejemplos de StringSpec, FunSpec, BehaviorSpec, DescribeSpec en Estilos de Spec de Kotest
- Mocking — Configuración de MockK, mocking de corrutinas, captura de argumentos en MockK
- Tutorial TDD — Ciclo completo RED/GREEN/REFACTOR con EmailValidator en Flujo de Trabajo TDD para Kotlin
- Cobertura — Configuración y comandos de Kover en Cobertura Kover
- Pruebas Ktor — Configuración de testApplication en Pruebas con testApplication de Ktor
Flujo de Trabajo TDD para Kotlin
El Ciclo RED-GREEN-REFACTOR
RED -> Escribe una prueba que falle primero
GREEN -> Escribe el código mínimo para que la prueba pase
REFACTOR -> Mejora el código mientras las pruebas sigan en verde
REPEAT -> Continúa con el siguiente requisito
TDD Paso a Paso en Kotlin
// Paso 1: Define la interfaz/firma
// EmailValidator.kt
package com.example.validator
fun validateEmail(email: String): Result<String> {
TODO("not implemented")
}
// Paso 2: Escribe la prueba que falla (RED)
// 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 devuelve éxito" {
validateEmail("[email protected]").shouldBeSuccess("[email protected]")
}
"email vacío devuelve fallo" {
validateEmail("").shouldBeFailure()
}
"email sin @ devuelve fallo" {
validateEmail("userexample.com").shouldBeFailure()
}
})
// Paso 3: Ejecuta las pruebas - verifica FALLO
// $ ./gradlew test
// EmailValidatorTest > valid email returns success FAILED
// kotlin.NotImplementedError: An operation is not implemented
// Paso 4: Implementa el código mínimo (GREEN)
fun validateEmail(email: String): Result<String> {
if (email.isBlank()) return Result.failure(IllegalArgumentException("El email no puede estar vacío"))
if ('@' !in email) return Result.failure(IllegalArgumentException("El email debe contener @"))
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)
}
// Paso 5: Ejecuta las pruebas - verifica PASO
// $ ./gradlew test
// EmailValidatorTest > valid email returns success PASSED
// EmailValidatorTest > empty email returns failure PASSED
// EmailValidatorTest > email without @ returns failure PASSED
// Paso 6: Refactoriza si es necesario, verifica que las pruebas sigan pasando
Estilos de Spec de Kotest
StringSpec (El más simple)
class CalculatorTest : StringSpec({
"sumar dos números positivos" {
Calculator.add(2, 3) shouldBe 5
}
"sumar números negativos" {
Calculator.add(-1, -2) shouldBe -3
}
"sumar cero" {
Calculator.add(0, 5) shouldBe 5
}
})
FunSpec (Estilo JUnit)
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser devuelve usuario cuando se encuentra") {
val expected = User(id = "1", name = "Alice")
coEvery { repository.findById("1") } returns expected
val result = service.getUser("1")
result shouldBe expected
}
test("getUser lanza excepción cuando no se encuentra") {
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("una solicitud de pedido válida") {
val request = CreateOrderRequest(
userId = "user-1",
items = listOf(OrderItem("product-1", quantity = 2)),
)
When("se realiza el pedido") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Success
coEvery { repository.save(any()) } answers { firstArg() }
val result = service.placeOrder(request)
Then("debería devolver un pedido confirmado") {
result.status shouldBe OrderStatus.CONFIRMED
}
Then("debería cobrar el pago") {
coVerify(exactly = 1) { paymentService.charge(any()) }
}
}
When("el pago falla") {
coEvery { paymentService.charge(any()) } returns PaymentResult.Declined
Then("debería lanzar PaymentException") {
shouldThrow<PaymentException> {
service.placeOrder(request)
}
}
}
}
})
DescribeSpec (Estilo RSpec)
class UserValidatorTest : DescribeSpec({
describe("validateUser") {
val validator = UserValidator()
context("con entrada válida") {
it("acepta un usuario normal") {
val user = CreateUserRequest("Alice", "[email protected]")
validator.validate(user).shouldBeValid()
}
}
context("con nombre inválido") {
it("rechaza nombre vacío") {
val user = CreateUserRequest("", "[email protected]")
validator.validate(user).shouldBeInvalid()
}
it("rechaza nombre que excede la longitud máxima") {
val user = CreateUserRequest("A".repeat(256), "[email protected]")
validator.validate(user).shouldBeInvalid()
}
}
}
})
Matchers de Kotest
Matchers Principales
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.*
// Igualdad
result shouldBe expected
result shouldNotBe unexpected
// Cadenas
name shouldStartWith "Al"
name shouldEndWith "ice"
name shouldContain "lic"
name shouldMatch Regex("[A-Z][a-z]+")
name.shouldBeBlank()
// Colecciones
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
// Excepciones
shouldThrow<IllegalArgumentException> {
validateAge(-1)
}.message shouldBe "La edad debe ser positiva"
shouldNotThrow<Exception> {
validateAge(25)
}
Matchers Personalizados
fun beActiveUser() = object : Matcher<User> {
override fun test(value: User) = MatcherResult(
value.isActive && value.lastLogin != null,
{ "El usuario ${value.id} debería estar activo con un último inicio de sesión" },
{ "El usuario ${value.id} no debería estar activo" },
)
}
// Uso
user should beActiveUser()
MockK
Mocking Básico
class UserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val logger = mockk<Logger>(relaxed = true) // Relaxed: devuelve valores por defecto
val service = UserService(repository, logger)
beforeTest {
clearMocks(repository, logger)
}
test("findUser delega al repositorio") {
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 devuelve null para id desconocido") {
every { repository.findById(any()) } returns null
val result = service.findUser("desconocido")
result.shouldBeNull()
}
})
Mocking de Corrutinas
class AsyncUserServiceTest : FunSpec({
val repository = mockk<UserRepository>()
val service = UserService(repository)
test("getUser función suspendida") {
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 con retardo") {
coEvery { repository.findById("1") } coAnswers {
delay(100) // Simula trabajo asíncrono
User(id = "1", name = "Alice")
}
val result = service.getUser("1")
result.name shouldBe "Alice"
}
})
Captura de Argumentos
test("save captura el argumento de usuario") {
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 y Mocking Parcial
test("spy sobre objeto real") {
val realService = UserService(repository)
val spy = spyk(realService)
every { spy.generateId() } returns "fixed-id"
spy.createUser(request)
verify { spy.generateId() } // Sobrescrito
// Otros métodos usan implementación real
}
Pruebas de Corrutinas
runTest para Funciones Suspendidas
import kotlinx.coroutines.test.runTest
class CoroutineServiceTest : FunSpec({
test("búsquedas concurrentes se completan juntas") {
runTest {
val service = DataService(testScope = this)
val result = service.fetchAllData()
result.users.shouldNotBeEmpty()
result.products.shouldNotBeEmpty()
}
}
test("tiempo de espera después de retardo") {
runTest {
val service = SlowService()
shouldThrow<TimeoutCancellationException> {
withTimeout(100) {
service.slowOperation() // Toma > 100ms
}
}
}
}
})
Pruebas de Flujos
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 actualizaciones") {
runTest {
val service = UserFlowService()
val emissions = service.observeUsers()
.take(3)
.toList()
emissions shouldHaveSize 3
emissions.last().shouldNotBeEmpty()
}
}
test("searchUsers aplica debounce a la 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") // Solo esto debería activar la búsqueda
advanceTimeBy(500)
results shouldHaveSize 1
job.cancel()
}
}
})
TestDispatcher
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
class DispatcherTest : FunSpec({
test("usa test dispatcher para ejecución controlada") {
val dispatcher = StandardTestDispatcher()
runTest(dispatcher) {
var completado = false
launch {
delay(1000)
completado = true
}
completado shouldBe false
advanceTimeBy(1000)
completado shouldBe true
}
}
})
Pruebas Basadas en Propiedades
Pruebas de Propiedades con 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: La prueba de ida y vuelta de serialización a continuación requiere que la clase de datos User
// esté anotada con @Serializable (de kotlinx.serialization).
class PropertyTest : FunSpec({
test("invertir cadena es involutivo") {
forAll<String> { s ->
s.reversed().reversed() == s
}
}
test("ordenar lista es idempotente") {
forAll(Arb.list(Arb.int())) { list ->
list.sorted() == list.sorted().sorted()
}
}
test("ida y vuelta de serialización preserva datos") {
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
}
}
})
Generadores Personalizados
import io.kotest.property.Arb
import io.kotest.property.arbitrary.*
// Generador personalizado para emails válidos
fun Arb.Companion.email(
localPartSize: IntRange = 1..20,
domainSize: IntRange = 1..10
): Arb<String> = arb {
val localPart = Arb.string(localPartSize, Codepoint.alphanumeric()).bind()
val domain = Arb.string(domainSize, Codepoint.alphanumeric()).bind()
val tld = Arb.of("com", "org", "net", "io").bind()
"${localPart}@${domain}.${tld}"
}
// Generador de User aleatorio
fun Arb.Companion.user(): Arb<User> = arb {
val id = Arb.uuid().bind().toString()
val name = Arb.string(3..30).bind()
val email = Arb.email().bind()
val isActive = Arb.boolean().bind()
User(id = id, name = name, email = email, isActive = isActive)
}
// Uso en prueba
test("usuarios generados aleatoriamente tienen emails válidos") {
forAll(Arb.user()) { user ->
user.email.matches(Regex("^[\\w.-]+@[\\w.-]+\\.\\w+$"))
}
}
Cobertura Kover
Configuración de Gradle
// build.gradle.kts
plugins {
kotlin("jvm")
id("org.jetbrains.kotlinx.kover") version "0.8.0"
}
dependencies {
testImplementation("io.kotest:kotest-runner-junit5:5.9.0")
testImplementation("io.kotest:kotest-assertions-core:5.9.0")
testImplementation("io.kotest:kotest-property:5.9.0")
testImplementation("io.mockk:mockk:1.13.12")
}
kover {
reports {
filters {
excludes {
classes(
"*Application*",
"*Config*",
"*DTO*"
)
}
}
verify {
rule {
minBound(80)
}
}
}
}
Comandos de Cobertura
# Ejecutar pruebas con cobertura
./gradlew koverHtmlReport
# Informe en build/reports/kover/html/index.html
# Verificar cobertura mínima
./gradlew koverVerify
# Falla si la cobertura < 80%
# Informe XML (para CI/CD)
./gradlew koverXmlReport
# Informe en build/reports/kover/xml/report.xml
# Informe de cobertura por paquete
./gradlew koverHtmlReport --kotlin-project :app
# Informe específico del proyecto
Ejemplo de Verificación en CI
# .github/workflows/test.yml
name: Pruebas y Cobertura
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
- run: ./gradlew test koverVerify
Pruebas con testApplication de Ktor
Configuración Básica
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.testApplication
class UserRoutesTest : FunSpec({
test("GET /users devuelve lista") {
testApplication {
application {
module() // Tu módulo principal de Ktor
}
val response = client.get("/users")
response.status shouldBe HttpStatusCode.OK
response.bodyAsText() shouldContain "users"
}
}
test("POST /users crea usuario") {
testApplication {
application {
module()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody(CreateUserRequest("Alice", "[email protected]"))
}
response.status shouldBe HttpStatusCode.Created
}
}
test("GET /users/:id devuelve 404 para usuario inexistente") {
testApplication {
application {
module()
}
val response = client.get("/users/unknown-id")
response.status shouldBe HttpStatusCode.NotFound
}
}
})
Pruebas de Autenticación
test("ruta protegida requiere token") {
testApplication {
application {
module()
}
val response = client.get("/admin/dashboard")
response.status shouldBe HttpStatusCode.Unauthorized
}
}
test("ruta protegida con token válido") {
testApplication {
application {
module()
}
val token = generateTestToken()
val response = client.get("/admin/dashboard") {
bearerAuth(token)
}
response.status shouldBe HttpStatusCode.OK
}
}
Mejores Prácticas
- Usar los estilos de spec adecuados — StringSpec para pruebas simples, BehaviorSpec para BDD, DescribeSpec para pruebas de unidad jerárquicas
- Mockear por interfaz — Siempre mockear por interfaz, no clases concretas
- MockK vs Mockito — Preferir MockK sobre Mockito para proyectos Kotlin
- runTest para corrutinas — Usar
runTesten lugar derunBlockingpara pruebas de corrutinas - Pruebas basadas en propiedades — Usar
forAllen lugar de ejemplos manuales para cobertura más amplia - Cobertura por rama — Apuntar a > 80% de cobertura de rama (no solo de línea)
- Aislar archivos de prueba — Una clase de prueba por archivo de producción
- Nomenclatura — Nombrar los archivos de prueba como
{ClassName}Test.kto{ClassName}Spec.kt
Comandos
# Ejecutar todas las pruebas
./gradlew test
# Ejecutar clases de prueba específicas
./gradlew test --tests "*UserServiceTest"
# Ejecutar prueba individual (JUnit5)
./gradlew test --tests "*UserServiceTest.should create user*"
# Ver cobertura
./gradlew koverHtmlReport
# Verificar cobertura mínima
./gradlew koverVerify