Fundamentos de MockK: stubbing, verificación y captura
Reemplaza colaboradores reales con dobles de prueba controlables para verificar comportamiento, no implementación.
MockK es la biblioteca de mocking de facto para Kotlin. A diferencia de Mockito, fue escrita en Kotlin desde cero, asà que entiende los idiomas del lenguaje: las clases final (que lo son por defecto), las funciones de extensión, los objects, las corrutinas y los tipos nullables funcionan sin complicaciones. En un servicio de Spring Boot normalmente quieres probar una clase de forma aislada reemplazando sus colaboradores (repositorios, clientes, gateways) con mocks. Un mock registra cada interacción y te permite tanto programar sus respuestas como verificar cómo fue llamado. Agrega la dependencia con testImplementation("io.mockk:mockk:1.13.x") y estás listo.
Creas un mock con la función inline reified mockk<T>(). Por defecto un mock de MockK es 'estricto': cada método que llames sobre él debe estar stubbeado primero, de lo contrario la prueba falla con una MockKException clara que te dice qué llamada no fue configurada. Esta estrictez es una caracterÃstica, no una molestia: te obliga a ser explÃcito sobre exactamente qué interacciones necesita tu prueba, lo que hace que las pruebas documenten el contrato entre una clase y sus colaboradores. El stubbing se hace con el bloque every { }, que captura la llamada que quieres interceptar.
Dentro de every { } escribes la llamada exacta que quieres programar y luego encadenas returns para dar un resultado o throws para simular un fallo. Por ejemplo, every { userRepository.findById(42L) } returns user le dice al mock que devuelva tu fixture cuando se invoque findById con 42L. Puedes igualar cualquier argumento con el matcher any() en lugar de un literal, y puedes encadenar múltiples valores de retorno (returns a andThen b) para que llamadas sucesivas devuelvan cosas distintas. Usa throws para forzar el camino infeliz: every { client.fetch(any()) } throws IOException("boom") te permite verificar tu manejo de errores sin una red poco fiable.
Después de ejercitar el código bajo prueba, verificas las interacciones con verify { }. Un simple verify { service.save(any()) } comprueba que la llamada ocurrió al menos una vez. Para ser preciso con la cardinalidad, pasa el parámetro exactly: verify(exactly = 1) { repo.save(user) } verifica que ocurrió exactamente una vez, y verify(exactly = 0) { repo.delete(any()) } verifica que nunca ocurrió, lo cual es invaluable para protegerte de efectos secundarios accidentales. También existen verifyOrder y verifySequence cuando el orden de las llamadas importa. La verificación y el stubbing son pasos deliberadamente separados, asà que tus pruebas se leen como arrange, act, assert.
Los mocks estrictos pueden ser verbosos cuando un colaborador tiene muchos métodos que no te importan. Un mock relajado, creado con mockk<T>(relaxed = true) o mockk<T>(relaxedUnitFun = true), devuelve valores por defecto sensatos para cualquier llamada no stubbeada: cero para números, false para booleanos, colecciones vacÃas y un deep-stub nuevo para tipos objeto. La variante relaxedUnitFun solo relaja las funciones que devuelven Unit, que es el caso común de colaboradores fire-and-forget como loggers o publicadores de eventos, manteniéndote honesto con las llamadas que devuelven valores. Recurre a mocks relajados para reducir ruido, pero prefiere mocks estrictos cuando quieras que la prueba falle ruidosamente si se toca un método inesperado.
A veces verificar que un método fue llamado no es suficiente: necesitas inspeccionar el argumento que se pasó, sobre todo cuando el código bajo prueba construye un objeto internamente. Para eso sirve un CapturingSlot. Declara val slot = slot<Order>(), usa capture(slot) en lugar de un matcher dentro de every o verify, y tras la llamada lee slot.captured para hacer aserciones ricas sobre el valor. Es la forma más limpia de verificar, por ejemplo, que un servicio construyó un Order con el total correcto y un id generado. Para múltiples invocaciones usa mutableListOf<Order>() con capture(list) e inspecciona cada elemento capturado.
MockK también tiene soporte de primera clase para corrutinas. Usa coEvery { } para stubbear una función suspend y coVerify { } para verificarla, reflejando exactamente la API sÃncrona. Asà coEvery { repository.loadAsync(id) } returns user y coVerify(exactly = 1) { repository.loadAsync(id) } se comportan igual que sus contrapartes bloqueantes pero pueden llamarse dentro de un bloque runTest o runBlocking. Esta simetrÃa significa que no aprendes un modelo mental separado para el código asÃncrono: every se convierte en coEvery, verify en coVerify, y todo lo demás es idéntico.
Algunos hábitos mantienen sanas las pruebas con MockK. Limpia o resetea los mocks compartidos entre pruebas con clearMocks(...) o anota la clase de prueba con MockKExtension y usa campos @MockK / @RelaxedMockK; en JUnit 5 la extensión los conecta automáticamente. Prefiere verificar el comportamiento en la frontera (qué se le pidió hacer al colaborador) en vez de reimplementar la lógica en la prueba. Y resiste el sobre-mocking: si una dependencia es una función pura o un simple contenedor de datos, usa la versión real. Los mocks son más valiosos para I/O, tiempo, aleatoriedad y cualquier cosa lenta o no determinista, justo las costuras donde las pruebas unitarias se volverÃan frágiles.
Juntándolo todo, una prueba tÃpica de un servicio prepara sus mocks con every/coEvery y unos cuantos fixtures, actúa llamando al método público bajo prueba, y verifica con verify(exactly = n) más capturas con slot para los argumentos interesantes. Como MockK habla Kotlin de forma nativa, las pruebas resultantes son concisas y se leen casi como una especificación de cómo tu servicio colabora con el resto del sistema, que es precisamente lo que una buena prueba unitaria debe comunicar.
import io.mockk.*import org.junit.jupiter.api.Testimport kotlin.test.assertEqualsclass UserService(private val repo: UserRepository) {fun displayName(id: Long): String {val user = repo.findById(id) ?: error("not found")return user.name.uppercase()}}class UserServiceTest {private val repo = mockk<UserRepository>() // strict: every call must be stubbedprivate val service = UserService(repo)@Testfun `returns uppercased name`() {every { repo.findById(42L) } returns User(42L, "ada")val result = service.displayName(42L)assertEquals("ADA", result)verify(exactly = 1) { repo.findById(42L) } // exact cardinalityverify(exactly = 0) { repo.save(any()) } // guard: no accidental write}}
Strict mock: stub with every { } returns / throws, then verify call counts.
import io.mockk.*import org.junit.jupiter.api.Testimport org.junit.jupiter.api.assertThrowsimport java.io.IOExceptionclass SyncService(private val client: ApiClient, private val log: Logger) {fun sync(id: Long) {log.info("syncing " + id) // fire-and-forget, returns Unitclient.fetch(id)}}class SyncServiceTest {// relaxed = true: log.info(...) needs no explicit stubprivate val log = mockk<Logger>(relaxed = true)private val client = mockk<ApiClient>()private val service = SyncService(client, log)@Testfun `propagates client failure`() {every { client.fetch(any()) } throws IOException("boom")assertThrows<IOException> { service.sync(7L) }verify { log.info(any()) } // still called before the failure}}
throws for the unhappy path, plus a relaxed mock to silence uninteresting calls.
import io.mockk.*import org.junit.jupiter.api.Testimport kotlin.test.assertEqualsclass OrderService(private val repo: OrderRepository) {fun place(customerId: Long, total: Int) {repo.save(Order(id = 0L, customerId = customerId, total = total))}}class OrderServiceTest {private val repo = mockk<OrderRepository>(relaxed = true)private val service = OrderService(repo)@Testfun `saves order with correct total`() {val slot = slot<Order>()every { repo.save(capture(slot)) } returns Unitservice.place(customerId = 99L, total = 250)val saved = slot.capturedassertEquals(99L, saved.customerId)assertEquals(250, saved.total)}}
slot captures the argument the service builds internally so you can assert on it.
import io.mockk.*import kotlinx.coroutines.test.runTestimport org.junit.jupiter.api.Testimport kotlin.test.assertEqualsclass ProfileService(private val repo: ProfileRepository) {suspend fun greet(id: Long): String = "Hi " + repo.loadAsync(id).name}class ProfileServiceTest {private val repo = mockk<ProfileRepository>()private val service = ProfileService(repo)@Testfun `greets loaded profile`() = runTest {coEvery { repo.loadAsync(1L) } returns Profile(1L, "Lin")assertEquals("Hi Lin", service.greet(1L))coVerify(exactly = 1) { repo.loadAsync(1L) }}}
Coroutine support: coEvery / coVerify mirror every / verify for suspend functions.
🧠Comprueba tu comprensión
0/1 · 0/1 answered1. You write val m = mockk<UserRepository>() and your code under test calls m.findById(5L), but the test fails before reaching your assertions. What is the most likely cause?