CultivaLeads — Testing Suite

Patrones TDD con Kotest + MockK + Kover · Microservicio Kotlin/Ktor

Kotest 5.9 MockK 1.13 Kover 0.9 Ktor 2.3
🔄 Ciclo TDD — Red · Green · Refactor
PASO 1
🔴 RED
Escribir el test
que falla primero
PASO 2
🟢 GREEN
Código mínimo
para pasar el test
PASO 3
🔵 REFACTOR
Mejorar el código
manteniendo tests verdes
🧪 Paso 1 — RED: Tests para LeadScoringService
🔴 RED — Test falla (no existe implementación)
src/test/kotlin/com/cultivaleads/scoring/LeadScoringServiceTest.kt RED
package com.cultivaleads.scoring

import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.comparables.shouldBeLessThanOrEqualTo

class LeadScoringServiceTest : BehaviorSpec({

    val service = LeadScoringService()

    Given("un lead de agencia con todas las características positivas") {
        val lead = Lead(
            empresa     = "NovaMind AI",
            empleados   = 25,
            presupuesto = 12_000.0,
            urgencia    = Urgencia.ALTA,
            tieneWeb    = true,
        )

        When("se calcula el score") {
            val result = service.calcularScore(lead)

            Then("debe ser 100 (score máximo)") {
                result.puntuacion shouldBe 100
            }

            Then("la clasificación debe ser HOT") {
                result.clasificacion shouldBe Clasificacion.HOT
            }
        }
    }

    Given("un lead con presupuesto insuficiente y empresa pequeña") {
        val lead = Lead(
            empresa     = "FreelanceStudio",
            empleados   = 2,
            presupuesto = 1_000.0,
            urgencia    = Urgencia.BAJA,
            tieneWeb    = false,
        )

        When("se calcula el score") {
            val result = service.calcularScore(lead)

            Then("debe ser 0 (sin criterios cumplidos)") {
                result.puntuacion shouldBe 0
            }

            Then("la clasificación debe ser COLD") {
                result.clasificacion shouldBe Clasificacion.COLD
            }
        }
    }

    Given("cualquier lead") {
        val lead = Lead(
            empresa = "AnyCompany", empleados = 50,
            presupuesto = 20_000.0, urgencia = Urgencia.MEDIA,
            tieneWeb = true,
        )

        Then("el score nunca supera 100") {
            service.calcularScore(lead).puntuacion shouldBeLessThanOrEqualTo 100
        }
    }
})
$ ./gradlew test --tests "com.cultivaleads.scoring.LeadScoringServiceTest"
 
FAILED LeadScoringServiceTest > un lead con todas las características... > debe ser 100
error: unresolved reference: LeadScoringService
error: unresolved reference: Lead
error: unresolved reference: Urgencia
 
BUILD FAILED in 3s — 3 tests FAILED (compilation error)
🟢 Paso 2 — GREEN: Implementación mínima
🟢 GREEN — Código mínimo para pasar los tests
src/main/kotlin/com/cultivaleads/scoring/LeadScoringService.kt GREEN
package com.cultivaleads.scoring

data class Lead(
    val empresa     : String,
    val empleados   : Int,
    val presupuesto : Double,
    val urgencia    : Urgencia,
    val tieneWeb    : Boolean,
)

data class ScoreResult(
    val puntuacion     : Int,
    val clasificacion  : Clasificacion,
    val criteriosCumplidos : List<String>,
)

enum class Urgencia { ALTA, MEDIA, BAJA }
enum class Clasificacion { HOT, WARM, COLD }

class LeadScoringService {

    fun calcularScore(lead: Lead): ScoreResult {
        val criterios = mutableListOf<String>()
        var pts = 0

        if (lead.empleados > 10) {
            pts += 30
            criterios.add("Empresa >10 empleados (+30)")
        }
        if (lead.presupuesto > 5_000.0) {
            pts += 40
            criterios.add("Presupuesto >5.000€ (+40)")
        }
        if (lead.urgencia == Urgencia.ALTA) {
            pts += 20
            criterios.add("Urgencia alta (+20)")
        }
        if (lead.tieneWeb) {
            pts += 10
            criterios.add("Tiene web (+10)")
        }

        val score = minOf(pts, 100)
        val clasificacion = when {
            score >= 70 -> Clasificacion.HOT
            score >= 40 -> Clasificacion.WARM
            else        -> Clasificacion.COLD
        }

        return ScoreResult(score, clasificacion, criterios)
    }
}
$ ./gradlew test --tests "com.cultivaleads.scoring.LeadScoringServiceTest"
 
PASSED LeadScoringServiceTest > lead con todas las características > debe ser 100 45ms
PASSED LeadScoringServiceTest > lead con todas las características > clasificación HOT 3ms
PASSED LeadScoringServiceTest > lead con presupuesto insuficiente > debe ser 0 2ms
PASSED LeadScoringServiceTest > lead con presupuesto insuficiente > clasificación COLD 2ms
PASSED LeadScoringServiceTest > cualquier lead > score nunca supera 100 2ms
 
BUILD SUCCESSFUL in 8s — 5 tests PASSED
🎭 MockK — Testing del repositorio con suspend functions
src/test/kotlin/com/cultivaleads/lead/LeadServiceTest.kt MockK + Corrutinas
package com.cultivaleads.lead

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.kotest.matchers.nulls.shouldNotBeNull
import io.mockk.*

class LeadServiceTest : FunSpec({

    val repository    = mockk<LeadRepository>()
    val scoringService = mockk<LeadScoringService>()
    val service       = LeadService(repository, scoringService)

    beforeTest { clearMocks(repository, scoringService) }

    test("procesarLead guarda el lead con su score calculado") {
        val lead     = Lead("NovaMind AI", 25, 12_000.0, Urgencia.ALTA, true)
        val expected = ScoreResult(100, Clasificacion.HOT, listOf("Empresa >10 empleados (+30)"))
        val slot     = slot<LeadConScore>()

        every  { scoringService.calcularScore(lead) } returns expected
        coEvery { repository.guardar(capture(slot)) } returns "lead-001"

        val id = service.procesarLead(lead)

        id shouldBe "lead-001"
        slot.captured.score shouldBe 100
        slot.captured.clasificacion shouldBe Clasificacion.HOT
        coVerify(exactly = 1) { repository.guardar(any()) }
    }

    test("buscarLead retorna null cuando no existe") {
        coEvery { repository.buscarPorId("999") } returns null

        val result = service.buscarLead("999")

        result shouldBe null
        coVerify { repository.buscarPorId("999") }
    }

    test("listarLeadsHot delega filtrado al repositorio") {
        val leads = listOf(
            LeadConScore("NovaMind AI", 100, Clasificacion.HOT),
            LeadConScore("DataPulse",  85,  Clasificacion.HOT),
        )
        coEvery { repository.listarPorClasificacion(Clasificacion.HOT) } returns leads

        val result = service.listarLeadsHot()

        result.size shouldBe 2
        result.all { it.clasificacion == Clasificacion.HOT }.shouldBe(true)
    }
})
🌐 Ktor testApplication — Endpoint POST /leads
src/test/kotlin/com/cultivaleads/routes/LeadRoutesTest.kt Ktor Integration
package com.cultivaleads.routes

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json

class LeadRoutesTest : FunSpec({

    test("POST /leads crea un lead y retorna 201 con score") {
        testApplication {
            application {
                configureRouting()
                configureSerialization()
            }

            val response = client.post("/leads") {
                contentType(ContentType.Application.Json)
                setBody(
                    """
                    {
                      "empresa": "NovaMind AI",
                      "empleados": 25,
                      "presupuesto": 12000.0,
                      "urgencia": "ALTA",
                      "tieneWeb": true
                    }
                    """.trimIndent()
                )
            }

            response.status shouldBe HttpStatusCode.Created
            val body = Json.parseToJsonElement(response.bodyAsText())
            body.jsonObject["puntuacion"]!!.jsonPrimitive.int shouldBe 100
            body.jsonObject["clasificacion"]!!.jsonPrimitive.content shouldBe "HOT"
        }
    }

    test("POST /leads con body inválido retorna 400") {
        testApplication {
            application { configureRouting(); configureSerialization() }

            val response = client.post("/leads") {
                contentType(ContentType.Application.Json)
                setBody("""{"empresa": ""}""")
            }

            response.status shouldBe HttpStatusCode.BadRequest
        }
    }

    test("GET /leads/{id} retorna 404 si el lead no existe") {
        testApplication {
            application { configureRouting(); configureSerialization() }

            val response = client.get("/leads/id-inexistente-999")

            response.status shouldBe HttpStatusCode.NotFound
        }
    }
})
📊 Resultados de la Suite Completa
Estado Test Clase Tiempo
✓ PASS lead con todas las características → debe ser 100 LeadScoringServiceTest 45ms
✓ PASS lead con todas las características → clasificación HOT LeadScoringServiceTest 3ms
✓ PASS lead sin criterios → debe ser 0 LeadScoringServiceTest 2ms
✓ PASS lead sin criterios → clasificación COLD LeadScoringServiceTest 2ms
✓ PASS cualquier lead → score nunca supera 100 LeadScoringServiceTest 2ms
✓ PASS procesarLead guarda el lead con su score calculado LeadServiceTest 28ms
✓ PASS buscarLead retorna null cuando no existe LeadServiceTest 6ms
✓ PASS listarLeadsHot delega filtrado al repositorio LeadServiceTest 5ms
✓ PASS POST /leads crea un lead y retorna 201 con score LeadRoutesTest 312ms
✓ PASS POST /leads con body inválido retorna 400 LeadRoutesTest 18ms
✓ PASS GET /leads/{id} retorna 404 si no existe LeadRoutesTest 15ms
$ ./gradlew test koverHtmlReport koverVerify
 
Task :test
11 tests completed, 0 failed, 0 skipped
 
Task :koverHtmlReport
HTML report: build/reports/kover/html/index.html
 
Task :koverVerify
Coverage check passed: 94% >= 80% (minimum)
 
BUILD SUCCESSFUL in 22s
📈 Cobertura Kover — Informe Final
Cobertura Total
94%
Mínimo requerido: 80% ✓
LeadScoringService
100%
Lógica crítica de negocio ✓
LeadService
91%
API pública >90% ✓
LeadRoutes (Ktor)
87%
Rutas HTTP ✓
🗂 Estilos de Spec — Cuándo usar cada uno
StringSpec — Lo más simple
Tests unitarios simples sin contexto adicional
class Test : StringSpec({
  "add 2 + 3" {
    add(2, 3) shouldBe 5
  }
})
FunSpec — Estilo JUnit
Services con dependencies y beforeTest/afterTest
class Test : FunSpec({
  test("getUser finds user") {
    // arrange / act / assert
  }
})
BehaviorSpec — BDD
Lógica de negocio compleja con Given/When/Then
Given("valid lead") {
  When("scored") {
    Then("returns HOT") {
      // ...
    }
  }
}
DescribeSpec — RSpec
Validaciones con múltiples contextos de entrada
describe("validate") {
  context("valid input") {
    it("accepts user") {
      // ...
    }
  }
}
Buenas Prácticas — Do vs Don't

Hacer

  • Escribir el test ANTES de la implementación (TDD)
  • Usar coEvery / coVerify para suspend functions
  • Usar runTest para tests de corrutinas
  • Testear comportamiento, no implementación interna
  • Usar data class fixtures claros y nombrados
  • Mantener un estilo de spec por módulo (consistencia)
  • Verificar cobertura con Kover en cada PR

No hacer

  • Saltarse la fase RED del ciclo TDD
  • Mezclar JUnit con Kotest en el mismo proyecto
  • Usar Thread.sleep() en tests de corrutinas
  • Mockear data classes (usar instancias reales)
  • Testear métodos privados directamente
  • Ignorar tests intermitentes (flaky tests)
  • Hacer push sin verificar cobertura mínima