Fakes vs Mocks: eligiendo tus dobles de prueba
Los fakes prueban comportamiento, los mocks prueban conversaciones: elige el doble que coincida con lo que realmente estás probando.
Una prueba unitaria rara vez ejecuta tu código de forma aislada: tu servicio habla con un repositorio, un reloj, un bus de mensajes, un cliente HTTP. Un *doble de prueba* sustituye a esos colaboradores para que la prueba siga siendo rápida, determinista y enfocada. Los dos dobles que más usarás en Kotlin/Spring son los **fakes** y los **mocks**, y confundirlos es la causa más común de suites de pruebas frágiles y de poco valor. Un fake es una implementación real y funcional que toma un atajo: un `MutableMap` en memoria en lugar de Postgres. Un mock es un sustituto programable cuyas llamadas registras y verificas. Ambos reemplazan una dependencia, pero responden preguntas distintas.
Un **fake** se basa en *estado*: lo ejercitas como si fuera lo real y luego haces aserciones sobre el resultado observable. Un repositorio en memoria realmente almacena lo que guardas y lo devuelve en `findById`. Tu prueba se lee casi como código de producción —llama al servicio y luego comprueba el estado resultante— y sobrevive a las refactorizaciones, porque nunca fija *cómo* el servicio usa el repositorio, solo *qué* resultado final debe producirse. El costo es que debes escribir y mantener el fake, y este debe respetar el mismo contrato que la implementación real (mismas reglas de unicidad, mismo comportamiento ante nulos). Ese costo inicial vale la pena cuando muchas pruebas comparten la dependencia.
Un **mock** se basa en *interacción*: registra las llamadas que recibe y te permite verificarlas. Con MockK defines valores de retorno (`every { repo.findById(1) } returns user`) y afirmas que ciertas interacciones ocurrieron (`verify { bus.publish(any()) }`). Los mocks brillan cuando la *interacción en sÃ* es el comportamiento que te importa —"¿publicamos exactamente un evento?", "¿evitamos la red cuando la caché estaba caliente?"— o cuando un colaborador real es genuinamente incómodo de levantar. El peligro es la sobre-especificación: pruebas que afirman cada llamada se convierten en detectores de cambios que se rompen ante refactors inofensivos sin atrapar errores reales.
Una regla útil: **usa un fake para consultas y un mock para comandos que no puedas observar de otro modo.** Si puedes afirmar el resultado leyendo el estado de vuelta, prefiere el fake: prueba resultados, no implementación. Recurre a un mock cuando el efecto secundario no deja rastro observable en el lÃmite de tu prueba (un correo enviado, un evento publicado, una llamada a terceros) o cuando el colaborador es no determinista, lento o difÃcil de construir. Evita mockear tipos que no posees: envuelve ese SDK detrás de tu propia interfaz y haz un fake o mock de *esa*, para que un cambio en la API del proveedor no se propague por cientos de stubs.
Ambos dobles viven dentro de la **pirámide de pruebas**: una base amplia de pruebas unitarias rápidas y aisladas (donde pertenecen los fakes y mocks), una capa media más delgada de pruebas de integración que conectan componentes reales (un repositorio real contra Postgres en Testcontainers, un slice de `@SpringBootTest`), y unas pocas pruebas de extremo a extremo en la cima. Los dobles son una herramienta de pruebas unitarias; te permiten mantener esa base amplia y rápida. No son un sustituto de las pruebas de integración: un repositorio fake nunca podrá atrapar una consulta SQL rota o un mapeo JPA defectuoso. Por eso una prueba de integración debe verificar la implementación real que tu fake sustituye.
Sea cual sea el doble que elijas, estructura cada prueba con **Arrange-Act-Assert** (AAA, también llamado Given-When-Then). *Arrange* construye el sistema bajo prueba y sus dobles, y siembra cualquier estado inicial. *Act* realiza exactamente una llamada: el único comportamiento bajo prueba. *Assert* comprueba el resultado (estado para fakes, interacciones para mocks). Mantener estas fases visualmente separadas, con una sola acción lógica por prueba, hace que los fallos se expliquen solos y evita que las pruebas verifiquen tres cosas a la vez. Una prueba con dos secciones de Act suele ser dos pruebas disfrazadas de una.
Las corrutinas añaden un matiz: las funciones suspend. Usa `runTest` de `kotlinx-coroutines-test` para ejecutar código suspendido con un reloj virtual que avanza los delays automáticamente, de modo que una función que hace `delay(10_000)` termina al instante. MockK soporta funciones suspend de forma nativa con `coEvery { }` y `coVerify { }`: úsalas en lugar de `every`/`verify` siempre que la función stubbed sea `suspend`. Un fake simplemente marca sus métodos como `suspend` para coincidir con la interfaz; sin maquinaria especial, lo cual es un punto silencioso más a favor del fake.
En la práctica, una suite sana de Kotlin/Spring mezcla ambos. Usa fakes por defecto para tus repositorios y colaboradores de dominio: se leen bien, se refactorizan con seguridad y sirven de documentación viva del contrato. Reserva los mocks para las costuras genuinas donde debes afirmar que un mensaje salió del sistema, y mantén esas verificaciones al mÃnimo: afirma la única interacción que define la corrección, no cada llamada incidental. Combina esa disciplina con un AAA estricto y una prueba de integración real detrás de cada fake, y tu pirámide se mantendrá rápida en la base, confiable en el medio y barata de cambiar hasta arriba.
import kotlinx.coroutines.runBlockingimport java.util.concurrent.ConcurrentHashMapimport java.util.concurrent.atomic.AtomicLongdata class User(val id: Long = 0, val email: String)interface UserRepository {suspend fun save(user: User): Usersuspend fun findByEmail(email: String): User?}// The fake: honors the same contract (unique email) as the real repo.class FakeUserRepository : UserRepository {private val store = ConcurrentHashMap<Long, User>()private val seq = AtomicLong(0)override suspend fun save(user: User): User {val existing = findByEmail(user.email)require(existing == null || existing.id == user.id) { "email already taken: " + user.email }val saved = if (user.id == 0L) user.copy(id = seq.incrementAndGet()) else userstore[saved.id] = savedreturn saved}override suspend fun findByEmail(email: String): User? =store.values.firstOrNull { it.email == email }}fun main() = runBlocking {val repo = FakeUserRepository()val saved = repo.save(User(email = "ada@calc.io"))check(saved.id != 0L) { "id should be assigned" }check(repo.findByEmail("ada@calc.io") == saved)println("fake works: " + saved)}
A FAKE in-memory repository. It is a real, working implementation of the same interface the production JPA repository implements — so the service can't tell the difference. State-based: you save, then read back. Runnable on plain kotlin + coroutines (no Spring, no DB).
Arena IDEimport kotlinx.coroutines.test.runTestimport org.junit.jupiter.api.Testimport kotlin.test.assertEqualsimport kotlin.test.assertFailsWithclass RegistrationService(private val users: UserRepository) {suspend fun register(email: String): User {users.findByEmail(email)?.let { error("already registered") }return users.save(User(email = email))}}class RegistrationServiceTest {@Testfun `registers a brand-new user`() = runTest {// Arrangeval repo = FakeUserRepository()val service = RegistrationService(repo)// Actval result = service.register("grace@navy.mil")// Assert (on observable state, not interactions)assertEquals("grace@navy.mil", result.email)assertEquals(result, repo.findByEmail("grace@navy.mil"))}@Testfun `rejects a duplicate email`() = runTest {val repo = FakeUserRepository().also { it.save(User(email = "dup@x.io")) }val service = RegistrationService(repo)assertFailsWith<IllegalStateException> { service.register("dup@x.io") }}}
STATE-BASED test using the fake, written with strict Arrange-Act-Assert. We assert the OUTCOME (the user is persisted and retrievable), never how the service called the repo — so it survives refactoring. Uses JUnit5 + kotlin.test + runTest, so it is not browser-runnable.
import io.mockk.coEveryimport io.mockk.coVerifyimport io.mockk.mockkimport io.mockk.slotimport kotlinx.coroutines.test.runTestimport org.junit.jupiter.api.Testimport kotlin.test.assertEqualsdata class UserRegistered(val email: String)interface EventBus { suspend fun publish(event: UserRegistered) }class NotifyingRegistration(private val users: UserRepository,private val bus: EventBus,) {suspend fun register(email: String): User {val saved = users.save(User(email = email))bus.publish(UserRegistered(saved.email))return saved}}class NotifyingRegistrationTest {@Testfun `publishes exactly one UserRegistered event`() = runTest {// Arrange: fake for the query collaborator, mock for the command seamval repo = FakeUserRepository()val bus = mockk<EventBus>()val captured = slot<UserRegistered>()coEvery { bus.publish(capture(captured)) } returns Unitval service = NotifyingRegistration(repo, bus)// Actservice.register("linus@kernel.org")// Assert: the interaction IS the behavior under testcoVerify(exactly = 1) { bus.publish(any()) }assertEquals("linus@kernel.org", captured.captured.email)}}
INTERACTION-BASED test using MockK. Here the behavior we care about is a side effect with no observable trace in the test boundary: publishing exactly one event. We stub with coEvery (the function is suspend) and assert with coVerify. Note we mock the seam we OWN (EventBus), not a vendor SDK.
🧠Comprueba tu comprensión
0/1 · 0/1 answered1. Your service saves a user and you want to assert the user can be retrieved afterward. Which test double best fits, and why?