---
name: kotlin-testing
description: >
  Testing Kotlin con MockK, Turbine para Flow, runTest para coroutines,
  Kover para cobertura, Robolectric y Ktor test client. Incluye testing
  de ViewModels con StateFlow y UI testing con Compose. Cargar cuando se
  escriban tests Kotlin, tests de ViewModel o se configure cobertura en Android.
version: "1.0.0"
herramientasPermitidas: [Read, Grep]
exclusiones:
  - "No cargar para implementar el código Kotlin de producción — primero implementar con `kotlin-experto` o `kotlin-compose`, luego escribir tests."
  - "No cargar para tests Java con JUnit/Mockito — Java tiene su propio ecosistema de testing; cargar `java-testing`."
  - "No cargar para errores de compilación Kotlin o Gradle en los tests — cargar `build-errors-kotlin`."
  - "No cargar para tests E2E de UI Android con Espresso — Espresso no está cubierto por este skill que se enfoca en unit/integration con MockK y Compose testing."
evolvable: true  # default para skill estandar
---
# Kotlin Testing — MockK, Turbine y runTest

## Cuándo NO cargar

- El código Kotlin de producción aún no existe — primero implementar con `kotlin-experto` o `kotlin-compose`, luego escribir tests.
- Los tests son para un proyecto Java con JUnit/Mockito — Java tiene su propio ecosistema; cargar `java-testing`.
- Los errores son de compilación Kotlin o Gradle en los archivos de test — cargar `build-errors-kotlin`.
- La pregunta es sobre tests E2E de UI Android con Espresso — Espresso no está cubierto por este skill.

## runTest — Tests de Coroutines

```kotlin
class FacturasViewModelTest {

    // TestDispatcher controla el tiempo virtual
    private val testDispatcher = UnconfinedTestDispatcher()

    @Before
    fun setup() {
        Dispatchers.setMain(testDispatcher)
    }

    @After
    fun teardown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `cargarFacturas emite Exito cuando repositorio retorna datos`() = runTest {
        // Arrange
        val repo = mockk<IFacturasRepository>()
        val facturas = listOf(Factura(id = "1", folio = "F-001"))
        every { repo.observarFacturas(any()) } returns flowOf(facturas)
        val vm = FacturasViewModel(repo, fakeSavedStateHandle("empresaId" to "empresa-1"))

        // Act + Assert con Turbine
        vm.uiState.test {
            skipItems(1)  // saltar Cargando inicial
            val estado = awaitItem()
            assertIs<FacturasUiState.Exito>(estado)
            assertEquals(1, estado.facturas.size)
            cancelAndConsumeRemainingEvents()
        }
    }
}
```

---

## Turbine — Testing de Flow

```kotlin
// Turbine envuelve un Flow y permite aserciones item por item
@Test
fun `observarFacturas emite lista actualizada al insertar`() = runTest {
    val dao = FakeFacturasDao()

    dao.observarTodas().test {
        // Primera emisión — lista vacía
        assertEquals(emptyList(), awaitItem())

        // Insertar elemento
        dao.insertar(FacturaEntity(id = "1", folio = "F-001"))

        // Segunda emisión — con el elemento
        val lista = awaitItem()
        assertEquals(1, lista.size)
        assertEquals("F-001", lista[0].folio)

        cancelAndConsumeRemainingEvents()
    }
}

// Verificar que Flow completa sin errores
@Test
fun `obtenerFacturasUnica completa sin error`() = runTest {
    val flow = repo.obtenerFacturasUnica("empresa-1")
    flow.test {
        val items = awaitItem()
        assertTrue(items.isNotEmpty())
        awaitComplete()
    }
}

// Verificar error en Flow
@Test
fun `Flow emite error cuando falla la red`() = runTest {
    every { api.obtenerFacturas(any()) } throws IOException("sin red")
    repo.observarFacturas("empresa-1").test {
        awaitError().also { error ->
            assertIs<IOException>(error)
        }
    }
}
```

---

## MockK — Sintaxis completa

```kotlin
// Mocks básicos
val repo = mockk<IFacturasRepository>()
val repo = mockk<IFacturasRepository>(relaxed = true)  // stubs automáticos para Unit

// Stubs síncronos y async
every { repo.obtenerPorId("1") } returns Factura(id = "1")
coEvery { repo.obtenerAsync("1") } returns Factura(id = "1")
coEvery { repo.obtenerAsync("inexistente") } returns null

// Stubs de Flow
every { repo.observarFacturas(any()) } returns flowOf(listaFacturas)
every { repo.observarFacturas("vacio") } returns emptyFlow()

// Capturar argumento
val slot = slot<Factura>()
coEvery { repo.guardar(capture(slot)) } returns Unit
// Después del call:
assertEquals("F-001", slot.captured.folio)

// Verificaciones
coVerify(exactly = 1) { repo.guardar(any()) }
coVerify(exactly = 0) { repo.eliminar(any()) }

// Verificar orden
coVerifyOrder {
    repo.validar(any())
    repo.guardar(any())
}

// Spy — objeto real con algunos métodos substituidos
val realService = FacturasService(repo)
val spyService = spyk(realService)
coEvery { spyService.validarFolio(any()) } returns true
```

---

## Kover — Cobertura

```kotlin
// build.gradle.kts (módulo :app)
plugins {
    id("org.jetbrains.kotlinx.kover") version "0.8.3"
}

kover {
    reports {
        filters {
            excludes {
                classes(
                    "*.BuildConfig",
                    "*.databinding.*",
                    "Hilt_*",
                    "*_Factory"
                )
            }
        }
        verify {
            rule {
                minBound(80)  // 80% cobertura mínima de líneas
            }
        }
    }
}
```

```bash
# Generar reporte
./gradlew koverHtmlReport

# Verificar umbral (falla si < 80%)
./gradlew koverVerify
```

---

## Compose UI Testing

```kotlin
class FacturasScreenTest {

    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun `pantalla muestra lista cuando estado es Exito`() {
        val facturas = listOf(Factura(id = "1", folio = "F-001", total = 1000.0))
        val estado = FacturasUiState.Exito(facturas)

        composeRule.setContent {
            FacturasScreen(uiState = estado, onFacturaClick = {})
        }

        composeRule.onNodeWithText("F-001").assertIsDisplayed()
        composeRule.onNodeWithText("$1,000.00").assertIsDisplayed()
    }

    @Test
    fun `pantalla muestra loader cuando estado es Cargando`() {
        composeRule.setContent {
            FacturasScreen(uiState = FacturasUiState.Cargando, onFacturaClick = {})
        }

        composeRule.onNodeWithContentDescription("Cargando").assertIsDisplayed()
    }

    @Test
    fun `clic en factura llama al callback`() {
        var clickedId: String? = null
        val facturas = listOf(Factura(id = "1", folio = "F-001", total = 0.0))

        composeRule.setContent {
            FacturasScreen(
                uiState = FacturasUiState.Exito(facturas),
                onFacturaClick = { clickedId = it }
            )
        }

        composeRule.onNodeWithText("F-001").performClick()
        assertEquals("1", clickedId)
    }
}
```

---

Para Robolectric (tests Android sin emulador), Ktor test client y el patrón
Fake vs Mock con ejemplos de implementación, ver
[recursos/testing-avanzado.md](recursos/testing-avanzado.md).

---

## Gotchas / Errores comunes no obvios

**`runBlocking` en tests Android hace que los tests fallen en el thread principal de Robolectric**: usar `runBlocking` en tests con `@RunWith(RobolectricTestRunner::class)` bloquea el hilo principal que Robolectric necesita para procesar mensajes del Looper. Fix: usar `runTest` con `UnconfinedTestDispatcher` para tests de Android que necesitan el thread principal — `runTest` gestiona el dispatcher de forma compatible con Robolectric.

**Turbine `awaitItem()` sin `cancelAndIgnoreRemainingEvents()` al final hace que el test se cuelgue**: si el Flow emite más items de los que el test consume con `awaitItem()`, Turbine espera que el test explícitamente cancele o finalice el Flow. Fix: siempre terminar los bloques `turbineScope { ... }` con `cancelAndIgnoreRemainingEvents()` o `awaitComplete()` según el caso.

**`every { mock.suspend() } returns value` sin `coEvery` causa error de compilación**: MockK distingue entre funciones suspendidas y normales — usar `every` en una función suspendida genera un error críptico de tipo. Fix: usar siempre `coEvery { mock.suspendFn() } returns value` para mockear funciones `suspend`, y `coVerify` para verificarlas.

**`Dispatchers.setMain(testDispatcher)` en `@BeforeEach` sin `resetMain()` en `@AfterEach` contamina tests subsecuentes**: el dispatcher del Main persiste entre tests porque es un estado global de coroutines. Fix: siempre parear `Dispatchers.setMain(testDispatcher)` con `Dispatchers.resetMain()` en la fase de teardown, o usar la extensión `@ExtendWith(MainDispatcherRule::class)` de Coroutines Test.

## Checklist de tests Kotlin

- [ ] runTest en todos los tests de coroutines (no runBlocking en tests de Android)
- [ ] Dispatchers.setMain(testDispatcher) en @Before y resetMain() en @After
- [ ] Turbine usado para tests de Flow (no collect + lista manual)
- [ ] CancellationException nunca atrapada en helpers de test
- [ ] Kover configurado con umbral mínimo 80%
- [ ] Fakes para integración, mocks (MockK) para verificación de comportamiento
