Communitygithub.com

patrones-de-prueba-kotlin

Patrones de prueba en Kotlin con Kotest, MockK, pruebas de corrutinas, pruebas basadas en propiedades y cobertura con Kover. Sigue la metodología TDD con prácticas idiomáticas de Kotlin. Útil al escribir pruebas en Kotlin con Kotest o MockK, o al probar corrutinas y verificar cobertura.

¿Qué es patrones-de-prueba-kotlin?

Este skill proporciona una guía completa y práctica para escribir pruebas robustas y mantenibles en Kotlin, siguiendo la metodología TDD (Red-Green-Refactor) y utilizando las bibliotecas más populares del ecosistema: Kotest para la escritura de especificaciones y aserciones, y MockK para el mocking de dependencias, incluyendo soporte para corrutinas. Cubre una amplia variedad de estilos de especificación de Kotest (StringSpec, FunSpec, BehaviorSpec, DescribeSpec), aserciones integradas y personalizadas, técnicas avanzadas de mocking (captura de argumentos, espías), pruebas de corrutinas y flujos con runTest y TestDispatchers, y pruebas basadas en propiedades con generadores personalizados. Además, incluye instrucciones para configurar y ejecutar Kover para medir la cobertura de código, y un ejemplo detallado paso a paso del ciclo TDD con un validador de email. Está diseñado para desarrolladores que trabajan en proyectos Kotlin y buscan mejorar la calidad de su código mediante pruebas automatizadas sólidas.

Compatible con~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/affaan-m/ECC/tree/main/skills/kotlin-testing

Preguntar en tu IA favorita

Abre un nuevo chat con esta habilidad de agente ya precargada.

Documentación

Patrones de Prueba en Kotlin

Patrones completos de prueba en Kotlin para escribir pruebas fiables y mantenibles siguiendo la metodología TDD con Kotest y MockK.

Cuándo Usar

  • Al escribir nuevas funciones o clases en Kotlin
  • Al agregar cobertura de prueba 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

  1. Identificar el código objetivo — Encuentra la función, clase o módulo a probar
  2. Escribir una especificación Kotest — Elige un estilo de especificación (StringSpec, FunSpec, BehaviorSpec) que se ajuste al alcance de la prueba
  3. Mockear dependencias — Usa MockK para aislar la unidad bajo prueba
  4. Ejecutar pruebas (ROJO) — Verifica que la prueba falle con el error esperado
  5. Implementar código (VERDE) — Escribe el código mínimo para pasar la prueba
  6. Refactorizar — Mejora la implementación manteniendo las pruebas en verde
  7. Verificar cobertura — Ejecuta ./gradlew koverHtmlReport y verifica una cobertura superior al 80%

Ejemplos

Las siguientes secciones contienen ejemplos detallados y ejecutables para cada patrón de prueba:

Referencia Rápida

Flujo de Trabajo TDD para Kotlin

El Ciclo ROJO-VERDE-REFACTOR

ROJO     -> Escribe una prueba que falle primero
VERDE    -> Escribe el código mínimo para pasar la prueba
REFACTOR -> Mejora el código manteniendo las pruebas en verde
REPITE   -> Continúa con el siguiente requisito

TDD Paso a Paso en Kotlin

// Paso 1: Definir la interfaz/firma
// EmailValidator.kt
package com.example.validator

fun validateEmail(email: String): Result<String> {
    TODO("no implementado")
}

// Paso 2: Escribir prueba que falla (ROJO)
// 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: Ejecutar pruebas - verificar FALLO
// $ ./gradlew test
// EmailValidatorTest > valid email returns success FAILED
//   kotlin.NotImplementedError: An operation is not implemented

// Paso 4: Implementar código mínimo (VERDE)
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: Ejecutar pruebas - verificar ÉXITO
// $ ./gradlew test
// EmailValidatorTest > valid email returns success PASSED
// EmailValidatorTest > empty email returns failure PASSED
// EmailValidatorTest > email without @ returns failure PASSED

// Paso 6: Refactorizar si es necesario, verificar que las pruebas sigan pasando

Estilos de Especificación Kotest

StringSpec (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) // Relajado: devuelve valores por defecto
    val service = UserService(repository, logger)

    beforeTest {
        clearMocks(repository, logger)
    }

    test("findUser delega en el 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("unknown")

        result.shouldBeNull()
    }
})

Mocking de Corrutinas

class AsyncUserServiceTest : FunSpec({
    val repository = mockk<UserRepository>()
    val service = UserService(repository)

    test("función suspensiva getUser") {
        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) // Simular 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()
}

Espía y Mocking Parcial

test("espiar 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 la 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 desbota 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 desencadenar 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 completed = false

            launch {
                delay(1000)
                completed = true
            }

            completed shouldBe false
            advanceTimeBy(1000)
            completed 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("la ida y vuelta de serialización conserva los 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

class CustomGenerators : FunSpec({
    test("generador personalizado de direcciones IP") {
        val ipGenerator = Arb.bind(
            Arb.int(0..255),
            Arb.int(0..255),
            Arb.int(0..255),
            Arb.int(0..255)
        ) { o1, o2, o3, o4 ->
            "$o1.$o2.$o3.$o4"
        }

        checkAll(ipGenerator) { ip ->
            ip.count { it == '.' } shouldBe 3
        }
    }
})

Pruebas con testApplication de Ktor

import io.kotest.core.spec.style.StringSpec
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*

class ApplicationTest : StringSpec({
    "la solicitud GET devuelve estado OK" {
        testApplication {
            application {
                // Configurar módulo de aplicación
                module()
            }

            val response = client.get("/api/health")

            response.status shouldBe HttpStatusCode.OK
        }
    }

    "la solicitud POST de crear usuario debería responder 201" {
        testApplication {
            application {
                module()
            }

            val response = client.post("/api/users") {
                contentType(ContentType.Application.Json)
                setBody("""{"name":"Alice","email":"[email protected]"}""")
            }

            response.status shouldBe HttpStatusCode.Created
        }
    }

    "la solicitud GET de usuario devuelve 404 para usuario no encontrado" {
        testApplication {
            application {
                module()
            }

            val response = client.get("/api/users/999")

            response.status shouldBe HttpStatusCode.NotFound
        }
    }
})

Cobertura con Kover

Configuración de Gradle

// build.gradle.kts
plugins {
    kotlin("jvm") version "1.9.0"
    id("org.jetbrains.kotlinx.kover") version "0.7.5"
}

kover {
    reports {
        filters {
            excludes {
                classes(
                    "*.Exceptions",
                    "*DTO",
                    "*Request",
                    "*Response"
                )
            }
        }
    }
}

Comandos

# Generar informe de cobertura HTML
./gradlew koverHtmlReport

# Verificar regla de cobertura mínima
./gradlew koverVerify

# Informe XML para integración CI/CD
./gradlew koverXmlReport

# Informe de cobertura para un módulo específico
./gradlew :module-name:koverHtmlReport

# Limpiar y regenerar informes
./gradlew clean koverHtmlReport

Mejores Prácticas

  1. Usar nombres de prueba descriptivos — Los nombres deben indicar claramente qué se está probando
  2. Aislar pruebas — Cada prueba debe enfocarse en una sola funcionalidad
  3. Seguir el patrón Triple A — Arrange-Act-Assert (Organizar-Actuar-Assertar)
  4. Evitar lógica en pruebas — Sin condicionales ni bucles en las pruebas
  5. Usar pruebas parametrizadas — Para probar múltiples entradas con la misma lógica
  6. Escribir pruebas para casos borde — Entradas vacías, nulos, valores límite
  7. Mantener las pruebas rápidas — Las pruebas unitarias deben ejecutarse en milisegundos
  8. Mockear límites externos — Aislarse de bases de datos, APIs y archivos
  9. Seguir el ROJO-VERDE-REFACTOR — Escribir prueba fallida, hacerla pasar, luego mejorar
  10. Apuntar a cobertura > 80% — Usar Kover para rastrear código no probado

Individual skills in this repo

This repo contains 20 individual skills — each has its own dedicated page.

accessibility

Design, implement, and audit inclusive digital products using WCAG 2.2 Level AA. Use when building or auditing UI that must meet WCAG 2.2 Level AA, or when reviewing a change for keyboard, contrast, or screen-reader support.

affaan-m/content-engine

Create platform-native content systems for X, LinkedIn, TikTok, YouTube, newsletters, and repurposed multi-platform campaigns. Use when the user wants social posts, threads, scripts, content calendars, or one source asset adapted cleanly across platforms.

affaan-m/ECC

Multi-platform content distribution across X, LinkedIn, Threads, and Bluesky. Adapts content per platform using content-engine patterns. Never posts identical content cross-platform. Use when the user wants to distribute content across social platforms.

affaan-m/fal-ai-media

Unified media generation via fal.ai MCP — image, video, and audio. Covers text-to-image (Nano Banana), text/image-to-video (Seedance, Kling, Veo 3), text-to-speech (CSM-1B), and video-to-audio (ThinkSound). Use when the user wants to generate images, videos, or audio with AI.

affaan-m/manim-video

日本語翻訳:このファイルは manim-video 用の日本語翻訳が必要です

affaan-m/remotion-video-creation

Remotion のベストプラクティス - React で動画を作成する。3D、アニメーション、音声、字幕、チャート、トランジションなどをカバーするドメイン固有の29のルール。

affaan-m/video-editing

AI-assisted video editing workflows for cutting, structuring, and augmenting real footage. Covers the full pipeline from raw capture through FFmpeg, Remotion, ElevenLabs, fal.ai, and final polish in Descript or CapCut. Use when the user wants to edit video, cut footage, create vlogs, or build video content.

agent-architecture-audit

Full-stack diagnostic for agent and LLM applications. Audits the 12-layer agent stack for wrapper regression, memory pollution, tool discipline failures, hidden repair loops, and rendering corruption. Produces severity-ranked findings with code-first fixes. Essential for developers building agent applications, autonomous loops, or any LLM-powered feature. Use when an agent or LLM feature misbehaves and the failing layer is unknown, or before shipping an agent stack.

agent-eval

Head-to-head comparison of coding agents (Claude Code, Aider, Codex, etc.) on custom tasks with pass rate, cost, time, and consistency metrics. Use when choosing between coding agents, or when a change to an agent setup needs measured pass rate, cost, and time rather than an impression.

agent-harness-construction

Design and optimize AI agent action spaces, tool definitions, and observation formatting for higher completion rates. Use when defining or revising an agent

agentic-engineering

Operate as an agentic engineer using eval-first execution, decomposition, and cost-aware model routing. Use when planning or executing engineering work that agents will carry out end to end.

agentic-os

Build persistent multi-agent operating systems on Claude Code. Covers kernel architecture, specialist agents, slash commands, file-based memory, scheduled automation, and state management without external databases. Use when building a persistent multi-agent system on Claude Code with its own memory, commands, and scheduling.

agent-introspection-debugging

Structured self-debugging workflow for AI agent failures using capture, diagnosis, contained recovery, and introspection reports. Use when an agent run fails and you need a reproducible diagnosis instead of a retry.

agent-payment-x402

Add x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Supports Base through agentwallet-sdk and X Layer through OKX Payments / OKX Agent Payments Protocol. Use when an agent must pay for something itself and needs per-task budgets, spending controls, and a non-custodial wallet.

agent-self-evaluation

Use after completing any non-trivial task. The agent self-rates its output on 5 axes — accuracy, completeness, clarity, actionability, conciseness — with concrete evidence per criterion. Produces a structured 1-5 scorecard with specific improvement suggestions.

agent-sort

Build an evidence-backed ECC install plan for a specific repo by sorting skills, commands, rules, hooks, and extras into DAILY vs LIBRARY buckets using parallel repo-aware review passes. Use when ECC should be trimmed to what a project actually needs instead of loading the full bundle.

ai-first-engineering

Engineering operating model for teams where AI agents generate a large share of implementation output. Use when setting team process, review gates, or ownership rules for a codebase largely written by agents.

ai-regression-testing

Regression testing strategies for AI-assisted development. Sandbox-mode API testing without database dependencies, automated bug-check workflows, and patterns to catch AI blind spots where the same model writes and reviews code. Use when adding regression coverage to AI-assisted code, or when the same model both wrote and reviewed a change.

android-clean-architecture

Clean Architecture patterns for Android and Kotlin Multiplatform projects — module structure, dependency rules, UseCases, Repositories, and data layer patterns. Use when structuring modules, layers, or data flow in an Android or KMP project.

angular-developer

Generates Angular code and provides architectural guidance. Trigger when creating projects, components, or services, or for best practices on reactivity (signals, linkedSignal, resource), forms, dependency injection, routing, SSR, accessibility (ARIA), animations, styling (component styles, Tailwind CSS), testing, or CLI tooling.

Skills relacionados