Patrones TDD con Kotest + MockK + Kover · Microservicio Kotlin/Ktor
LeadScoringService
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 } } })
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) } }
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) } })
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 } } })
| 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 |
coEvery / coVerify para suspend functionsrunTest para tests de corrutinasdata class fixtures claros y nombradosThread.sleep() en tests de corrutinas