MockK para Corrutinas: coEvery, coVerify, Flow y Mocks Estáticos/de Objeto
Simula y verifica funciones suspend y Flows de forma idiomática: coEvery y coVerify hacen por las corrutinas lo que every y verify hacen por el código bloqueante.
MockK es la librerÃa de mocking de facto para Kotlin, y su soporte para corrutinas es lo que la vuelve indispensable en una base de código de Spring Boot 3 reactiva o con suspend. La idea central es la simetrÃa: para funciones ordinarias defines el comportamiento con `every { }` y verificas las llamadas con `verify { }`, mientras que para funciones `suspend` usas los gemelos conscientes de corrutinas `coEvery { }` y `coVerify { }`. Estos builders con prefijo `co` ejecutan su lambda dentro de un contexto de corrutina, de modo que pueden llamar legalmente a funciones suspend, algo que un bloque `every { }` normal no puede. Usar `every` sobre una función suspend es el error más común de MockK con corrutinas y produce un error de compilación o un stub que nunca coincide.
Supón que tienes un `UserRepository` que expone `suspend fun findById(id: Long): User?`. Creas un mock con `mockk<UserRepository>()` y luego le enseñas cómo responder: `coEvery { repo.findById(1L) } returns User(1L, "Ada")`. El lado derecho acepta los mismos operadores que `every` — `returns`, `returnsMany`, `throws` y `coAnswers { }` para respuestas dinámicas que a su vez pueden suspender. Para simular latencia o un timeout puedes escribir `coEvery { repo.findById(any()) } coAnswers { delay(100); null }`. Tras ejercitar el sistema bajo prueba confirmas la interacción con `coVerify { repo.findById(1L) }`, opcionalmente afinándola con los parámetros `exactly = 1`, `atLeast` o `timeout`.
El orden de verificación y los conteos de llamadas funcionan igual que en la API bloqueante. `coVerify(exactly = 0) { repo.deleteById(any()) }` demuestra que una llamada destructiva nunca ocurrió, mientras que `coVerifyOrder { repo.lock(id); repo.findById(id); repo.unlock(id) }` asegura una secuencia precisa. Como el cuerpo de tu prueba llama a funciones suspend sobre el sistema bajo prueba, envuelve toda la prueba en `runTest { }` de `kotlinx-coroutines-test`. `runTest` proporciona un `TestScope` con un reloj virtual, asà que cualquier `delay` dentro de tus stubs `coAnswers` se omite instantáneamente en lugar de bloquear el hilo de prueba — tu suite sigue siendo rápida aunque el código de producción espere.
Las funciones que devuelven Flow merecen atención especial. Una función como `fun streamEvents(): Flow<Event>` *no* es una función suspend: devuelve un `Flow` frÃo de forma sÃncrona, asà que la simulas con `every { }` ordinario, no con `coEvery { }`. Devuelve un flow real construido con `flowOf(a, b, c)` o el builder `flow { }` cuando necesites emitir condicionalmente, suspender entre emisiones o lanzar a mitad del stream. Por ejemplo `every { repo.streamEvents() } returns flowOf(Event("created"), Event("updated"))` entrega dos valores al colector y luego completa. Para verificar lo recolectado, drena el flow dentro de `runTest` con `.toList()` y compara. Solo marca el stub con `coEvery` si la fábrica en sà está declarada como `suspend fun streamEvents(): Flow<Event>`.
A veces la dependencia que debes controlar no es una instancia inyectada sino una función de nivel superior o de extensión, un miembro de companion object, o un singleton `object` de Kotlin. Para estos MockK ofrece `mockkStatic`, `mockkObject` y `mockkConstructor`. `mockkStatic("com.example.TimeUtilsKt")` (nota el sufijo `Kt` que MockK usa para funciones a nivel de archivo) te permite simular una función libre como `now()`; `mockkObject(FeatureFlags)` convierte un singleton real en un spy que puedes sobrescribir selectivamente dejando intactos los demás miembros. Ambos se combinan de forma natural con corrutinas — `coEvery { SomeObject.fetch() } returns data` funciona una vez que el objeto está mockeado.
La disciplina crucial con los mocks estáticos y de objeto es la limpieza, porque parchean estado global que se filtra entre pruebas. Deshazlos siempre: llama a `unmockkStatic(...)` / `unmockkObject(...)` en un `@AfterEach`, o envuelve la región mockeada en `mockkStatic(...) { ... }` / `mockkObject(...) { ... }`, la forma de bloque que des-mockea automáticamente al salir. Un `unmockkAll()` general en el tear-down es una red de seguridad pragmática para suites grandes. Olvidar esto es el segundo error clásico: un `mockkObject(FeatureFlags)` filtrado corrompe silenciosamente toda prueba posterior que toque el singleton, produciendo fallos enloquecedores dependientes del orden.
Juntándolo todo para una prueba tÃpica de servicio Spring: anota la clase, declara `private val repo = mockk<UserRepository>()`, construye `UserService(repo)` a mano (la inyección por constructor hace trivial el cableado manual y evita un contexto Spring), y escribe cada prueba dentro de `runTest`. Usa `coEvery` para preparar stubs suspend, ejercita el servicio, y luego haz `coVerify` de las llamadas salientes. Relaja la estrictez de MockK solo donde ayude: `mockk<UserRepository>(relaxed = true)` auto-simula cada función con valores por defecto sensatos (cero, colecciones vacÃas, flows vacÃos), útil cuando solo te importan un par de interacciones, mientras que `relaxUnitFun = true` relaja solo las funciones que devuelven `Unit` y te mantiene honesto con el resto.
Unos pocos idiomas finales mantienen robustas las pruebas de corrutinas. Prefiere matchers de argumentos — `any()`, `eq()`, `match { it.id > 0 }` — en lugar de valores fijos cuando la entrada exacta es incidental. Captura argumentos para aserciones más ricas con `val slot = slot<User>(); coEvery { repo.save(capture(slot)) } returns Unit`, y luego inspecciona `slot.captured`. Cuando un stub suspend deba variar según la llamada, `coAnswers { firstArg<Long>().let { id -> store[id] } }` lee los argumentos reales. Y recuerda que `coVerify` también puede asegurar *presencia en el tiempo* con `coVerify(timeout = 500) { repo.flush() }`, que espera hasta medio segundo a una llamada disparada asÃncronamente antes de fallar — invaluable para corrutinas fire-and-forget lanzadas en un scope de fondo.
import io.mockk.coEveryimport io.mockk.coVerifyimport io.mockk.mockkimport kotlinx.coroutines.delayimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEqualsdata class User(val id: Long, val name: String)interface UserRepository {suspend fun findById(id: Long): User?suspend fun deleteById(id: Long)}class UserService(private val repo: UserRepository) {suspend fun displayName(id: Long): String =repo.findById(id)?.name ?: "unknown"}class UserServiceTest {private val repo = mockk<UserRepository>()private val service = UserService(repo)@Testfun `returns name from repository`() = runTest {// coEvery for suspend functions; coAnswers can itself suspendcoEvery { repo.findById(1L) } coAnswers { delay(50); User(1L, "Ada") }val name = service.displayName(1L)assertEquals("Ada", name)coVerify(exactly = 1) { repo.findById(1L) }coVerify(exactly = 0) { repo.deleteById(any()) }}}
Stubbing and verifying a suspend function. coEvery arranges the stub, runTest hosts the suspend calls, and coVerify asserts the interaction. Runs on plain stdlib + kotlinx.coroutines + MockK (no Spring).
import io.mockk.everyimport io.mockk.mockkimport kotlinx.coroutines.flow.Flowimport kotlinx.coroutines.flow.flowOfimport kotlinx.coroutines.flow.mapimport kotlinx.coroutines.flow.toListimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEqualsdata class Event(val type: String)interface EventRepository {fun streamEvents(): Flow<Event> // cold flow, returned synchronously}class EventReporter(private val repo: EventRepository) {fun titles(): Flow<String> = repo.streamEvents().map { it.type.uppercase() }}class EventReporterTest {private val repo = mockk<EventRepository>()@Testfun `transforms emitted events`() = runTest {// every (not coEvery): the factory is not a suspend functionevery { repo.streamEvents() } returns flowOf(Event("created"), Event("updated"))val result = EventReporter(repo).titles().toList()assertEquals(listOf("CREATED", "UPDATED"), result)}}
Mocking a Flow-returning function. streamEvents() is NOT suspend, so use plain every and return a real cold flow; collect with toList() inside runTest.
import io.mockk.coEveryimport io.mockk.everyimport io.mockk.mockkObjectimport io.mockk.mockkStaticimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEquals// Top-level function lives in TimeUtils.kt -> MockK class name "TimeUtilsKt"fun nowMillis(): Long = System.currentTimeMillis()object FeatureFlags {suspend fun isEnabled(key: String): Boolean = false // real impl hits a remote store}class StaticAndObjectMockTest {@Testfun `stub top-level fun and object singleton`() = runTest {// Block form: automatically unmocked when the lambda returnsmockkStatic(::nowMillis) {every { nowMillis() } returns 1_700_000_000_000LassertEquals(1_700_000_000_000L, nowMillis())}mockkObject(FeatureFlags) {coEvery { FeatureFlags.isEnabled("beta") } returns trueassertEquals(true, FeatureFlags.isEnabled("beta"))}// After the blocks, FeatureFlags and nowMillis are restored.}}
mockkStatic for a top-level function and mockkObject for a singleton, both with coroutine stubs. Note the block forms auto-unmock on exit, preventing global-state leakage.
import io.mockk.coEveryimport io.mockk.coVerifyOrderimport io.mockk.mockkimport io.mockk.slotimport kotlinx.coroutines.test.runTestimport kotlin.test.Testimport kotlin.test.assertEqualsinterface Store {suspend fun lock(id: Long)suspend fun read(id: Long): Intsuspend fun unlock(id: Long)}class Counter(private val store: Store) {suspend fun readUnderLock(id: Long): Int {store.lock(id)return try { store.read(id) } finally { store.unlock(id) }}}class CounterTest {private val store = mockk<Store>(relaxUnitFun = true)@Testfun `locks around the read in order`() = runTest {val idSlot = slot<Long>()// coAnswers reads the actual argument captured at call timecoEvery { store.read(capture(idSlot)) } coAnswers { (idSlot.captured * 10).toInt() }val value = Counter(store).readUnderLock(7L)assertEquals(70, value)coVerifyOrder {store.lock(7L)store.read(7L)store.unlock(7L)}}}
A self-contained runnable demonstration of coEvery + coAnswers reading real arguments and coVerify ordering. Uses only kotlinx.coroutines + MockK, no framework.
🧠Comprueba tu comprensión
0/1 · 0/1 answered1. You need to mock `fun streamEvents(): Flow<Event>` (a regular, non-suspend function that returns a cold Flow) and also `suspend fun findById(id: Long): User?`. Which pairing is correct?