Saltar a contenido

5.2.2.-MockK

5.2.2 MockK

Cuando una unidad de código depende de otra, aparece una pregunta importante: ¿cómo probamos una clase sin tener que ejecutar siempre sus dependencias reales? Ahí es donde entra MockK.

En la normativa de la unidad, este contenido se relaciona con el diseño y la automatización de pruebas, ya que MockK permite aislar dependencias, construir escenarios controlados y verificar colaboraciones dentro de un test unitario.

Código Descripción
RA3 Verifica el funcionamiento de programas diseñando y realizando pruebas.
CE b Se han definido casos de prueba.
CE f Se han efectuado pruebas unitarias de clases y funciones.
CE g Se han implementado pruebas automáticas.
CE i Se han utilizado dobles de prueba para aislar los componentes durante las pruebas.

Qué vas a aprender en este apartado

  • Entender para qué sirve una biblioteca de mocking en Kotlin.
  • Crear mocks, spies y mocks relajados con MockK.
  • Capturar argumentos y controlar colaboraciones entre objetos.
  • Simular singletons y funciones con retorno Unit.

1. Qué problema resuelve MockK

MockK es una biblioteca de mocking para Kotlin. Su objetivo es permitirnos simular colaboraciones entre objetos durante una prueba.

En la práctica, esto sirve para:

  • aislar la unidad que estamos probando;
  • controlar la respuesta de dependencias externas;
  • verificar que una colaboración ocurrió como esperábamos;
  • evitar llamadas reales a bases de datos, servicios remotos o componentes costosos.

En Kotlin esto cobra especial importancia porque muchas clases y métodos son finales por defecto, y no todas las bibliotecas de mocking manejan bien ese escenario. MockK está pensada precisamente para encajar mejor con las características del lenguaje.

Qué significa mockear

Mockear no es "falsear por falsear". Se trata de sustituir una dependencia real por un doble de prueba controlado para centrarnos en el comportamiento de la unidad que queremos verificar.

2. Instalación básica

La configuración mínima en Gradle suele quedar así:

val mockkVersion = "x.y.z"

dependencies {
    testImplementation("io.mockk:mockk:$mockkVersion")
}

Como criterio general, conviene consultar la documentación del proyecto para elegir una versión compatible con la versión de Kotlin y del motor de pruebas que uses.

3. Ejemplo básico de mock

Partimos de un servicio sencillo:

class TestableService {
    fun getDataFromDb(testParameter: String): String {
        error("Implementación real no disponible en este ejemplo")
    }

    fun doSomethingElse(testParameter: String): String {
        return "I don't want to!"
    }
}

Y ahora un test que simula su comportamiento:

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlin.test.Test
import kotlin.test.assertEquals

class BasicMockKTest {
    @Test
    fun givenServiceMock_whenCallingMockedMethod_thenCorrectlyVerified() {
        val service = mockk<TestableService>()

        every { service.getDataFromDb("Expected Param") } returns "Expected Output"

        val result = service.getDataFromDb("Expected Param")

        verify { service.getDataFromDb("Expected Param") }
        assertEquals("Expected Output", result)
    }
}

Las piezas importantes del ejemplo son:

  • mockk<T>() crea el objeto simulado;
  • every { ... } returns ... define su comportamiento;
  • verify { ... } comprueba que la interacción ocurrió.

4. Uso de anotaciones

MockK también permite declarar mocks mediante anotaciones. Esto resulta útil cuando el test tiene varias dependencias y quieres dejar más visible qué papel juega cada una.

import io.mockk.InjectMockKs
import io.mockk.MockK
import io.mockk.MockKAnnotations
import kotlin.test.BeforeTest

class InjectTestService {
    lateinit var service1: TestableService
    lateinit var service2: TestableService

    fun invokeService1(): String = service1.getDataFromDb("Test Param")
}

class AnnotationMockKUnitTest {

    @MockK
    lateinit var service1: TestableService

    @MockK
    lateinit var service2: TestableService

    @InjectMockKs
    var objectUnderTest = InjectTestService()

    @BeforeTest
    fun setUp() {
        MockKAnnotations.init(this)
    }
}

Aquí MockK intenta inyectar los mocks en el objeto bajo prueba, normalmente por nombre y después por tipo. Esto reduce bastante el trabajo repetitivo cuando el test tiene varias colaboraciones.

5. Spy: mezclar comportamiento real y simulado

Un spy crea un objeto que conserva su comportamiento real salvo en los métodos que decidimos sustituir.

import io.mockk.every
import io.mockk.spyk
import kotlin.test.Test
import kotlin.test.assertEquals

class SpyKExampleTest {
    @Test
    fun givenServiceSpy_whenMockingOnlyOneMethod_thenOtherMethodsShouldBehaveAsOriginalObject() {
        val service = spyk<TestableService>()

        every { service.getDataFromDb(any()) } returns "Mocked Output"

        val firstResult = service.getDataFromDb("Any Param")
        val secondResult = service.doSomethingElse("Any Param")

        assertEquals("Mocked Output", firstResult)
        assertEquals("I don't want to!", secondResult)
    }
}

Esto es útil cuando la clase real tiene mucho comportamiento válido y solo necesitas intervenir en una parte concreta.

También puede declararse con anotación:

import io.mockk.SpyK

class SpyKUnitTest {
    @SpyK
    lateinit var service: TestableService
}

6. Mocks relajados

Por defecto, un mock lanza una excepción si llamas a un método cuyo comportamiento no has definido. Cuando ese nivel de control no compensa, MockK permite usar mocks relajados.

import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals

class RelaxedMockKExampleTest {
    @Test
    fun givenRelaxedMock_whenCallingNotMockedMethod_thenReturnDefaultValue() {
        val service = mockk<TestableService>(relaxed = true)

        val result = service.getDataFromDb("Any Param")

        assertEquals("", result)
    }
}

En este caso, MockK devuelve valores por defecto según el tipo de retorno. Para String, devuelve una cadena vacía.

La variante con anotación sería:

import io.mockk.RelaxedMockK

class RelaxedMockKUnitTest {
    @RelaxedMockK
    lateinit var service: TestableService
}

7. Mockear objetos object

Kotlin permite declarar singletons con object. MockK proporciona mockkObject para poder simular ese tipo de elementos.

import io.mockk.every
import io.mockk.mockkObject
import kotlin.test.Test
import kotlin.test.assertEquals

object SingletonService {
    fun getDataFromDb(testParameter: String): String = "Real Output for $testParameter"
}

class ObjectMockKTest {
    @Test
    fun givenObject_whenMockingIt_thenMockedMethodShouldReturnProperValue() {
        mockkObject(SingletonService)

        val firstResult = SingletonService.getDataFromDb("Any Param")
        assertEquals("Real Output for Any Param", firstResult)

        every { SingletonService.getDataFromDb(any()) } returns "Mocked Output"

        val secondResult = SingletonService.getDataFromDb("Any Param")
        assertEquals("Mocked Output", secondResult)
    }
}

Este caso aparece menos en código bien desacoplado, pero existe, especialmente en utilidades, helpers o adaptadores heredados.

8. Mockeado jerárquico

Otra posibilidad es construir un mock que ya devuelva otros mocks en sus propiedades.

import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals

class Foo {
    lateinit var name: String
    lateinit var bar: Bar
}

class Bar {
    lateinit var nickname: String
}

class HierarchicalMockKTest {
    @Test
    fun givenHierarchicalClass_whenMockingIt_thenReturnProperValue() {
        val foo = mockk<Foo> {
            every { name } returns "Karol"
            every { bar } returns mockk {
                every { nickname } returns "Tomato"
            }
        }

        assertEquals("Karol", foo.name)
        assertEquals("Tomato", foo.bar.nickname)
    }
}

Este enfoque es útil cuando el objeto bajo prueba navega por varias propiedades encadenadas y quieres controlar ese recorrido.

9. Captura de argumentos

En ocasiones no basta con comprobar que se llamó a un método. También necesitamos saber con qué argumentos se invocó.

9.1. Usando slot

import io.mockk.capture
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlin.test.Test
import kotlin.test.assertEquals

class CapturingSlotTest {
    @Test
    fun givenMock_whenCapturingParamValue_thenProperValueShouldBeCaptured() {
        val service = mockk<TestableService>()
        val slot = slot<String>()

        every { service.getDataFromDb(capture(slot)) } returns "Expected Output"

        service.getDataFromDb("Expected Param")

        assertEquals("Expected Param", slot.captured)
    }
}

9.2. Usando una lista mutable

Si quieres almacenar varias invocaciones, una lista suele ser más cómoda:

import io.mockk.capture
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals

class CapturingListTest {
    @Test
    fun givenMock_whenCapturingParamsValues_thenProperValuesShouldBeCaptured() {
        val service = mockk<TestableService>()
        val calls = mutableListOf<String>()

        every { service.getDataFromDb(capture(calls)) } returns "Expected Output"

        service.getDataFromDb("Expected Param 1")
        service.getDataFromDb("Expected Param 2")

        assertEquals(2, calls.size)
        assertEquals("Expected Param 1", calls[0])
        assertEquals("Expected Param 2", calls[1])
    }
}

10. Stubbing de funciones que devuelven Unit

En Kotlin, un método que devuelve Unit no devuelve un valor útil, pero sigue pudiendo tener efectos laterales. MockK permite controlar ese comportamiento.

Partimos de este método:

class TestableServiceWithUnit {
    fun addHelloWorld(strList: MutableList<String>) {
        println("addHelloWorld() is called")
        strList += "Hello World!"
    }
}

10.1. Hacer que no haga nada

Podemos omitir la ejecución real de varias formas:

every { service.addHelloWorld(any()) } returns Unit
every { service.addHelloWorld(any()) } answers { Unit }
every { service.addHelloWorld(any()) } just runs

La forma más legible suele ser just runs.

import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import kotlin.test.Test
import kotlin.test.assertTrue

class UnitStubTest {
    @Test
    fun givenServiceMock_whenCallingMethodReturnsUnit_thenCorrectlyVerified() {
        val service = mockk<TestableServiceWithUnit>()
        val myList = mutableListOf<String>()

        every { service.addHelloWorld(any()) } just runs
        service.addHelloWorld(myList)

        assertTrue(myList.isEmpty())
    }
}

10.2. Llamar a la implementación original

Si en lugar de anular el comportamiento quieres ejecutar el original, puedes usar callOriginal().

import io.mockk.answers
import io.mockk.every
import io.mockk.mockk
import kotlin.test.Test
import kotlin.test.assertEquals

class CallOriginalTest {
    @Test
    fun givenServiceMock_whenCallingOriginalMethod_thenCorrectlyVerified() {
        val service = mockk<TestableServiceWithUnit>()
        val myList = mutableListOf<String>()

        every { service.addHelloWorld(any()) } answers { callOriginal() }
        service.addHelloWorld(myList)

        assertEquals(1, myList.size)
        assertEquals("Hello World!", myList.first())
    }
}

10.3. Combinar varios escenarios

El verdadero interés de callOriginal() aparece cuando no quieres el mismo comportamiento en todas las llamadas.

import io.mockk.answers
import io.mockk.every
import io.mockk.just
import io.mockk.match
import io.mockk.mockk
import io.mockk.runs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class TwoScenariosTest {
    @Test
    fun givenServiceMock_whenStubbingTwoScenarios_thenCorrectlyVerified() {
        val service = mockk<TestableServiceWithUnit>()
        val kaiList = mutableListOf("Kai")
        val emptyList = mutableListOf<String>()

        every { service.addHelloWorld(any()) } just runs
        every { service.addHelloWorld(match { "Kai" in it }) } answers { callOriginal() }

        service.addHelloWorld(kaiList)
        service.addHelloWorld(emptyList)

        assertEquals(listOf("Kai", "Hello World!"), kaiList)
        assertTrue(emptyList.isEmpty())
    }
}

En la práctica, esto te permite adaptar la simulación a distintos contextos sin renunciar al comportamiento real cuando realmente te interesa.

11. Cuándo conviene usar MockK y cuándo no

MockK es útil, pero también es fácil abusar de él. Como regla general:

  • úsalo cuando una dependencia hace costosa o inestable la prueba;
  • úsalo cuando necesitas verificar una interacción concreta;
  • evita mockear clases triviales que podrías sustituir por objetos reales simples;
  • no conviertas el test en una copia exacta de la implementación interna.

Si un test depende de demasiados mocks, muchas veces no es que falte MockK, sino que sobra acoplamiento en el diseño.

12. Conclusión

MockK es una herramienta muy práctica para escribir pruebas unitarias en Kotlin cuando necesitamos aislar colaboraciones, controlar respuestas y verificar interacciones. Bien usado, hace que los tests sean más precisos y más fáciles de mantener.

La idea clave con la que conviene quedarse es esta: mockear no consiste en falsear todo, sino en sustituir solo aquellas dependencias que impiden probar con claridad la unidad que te interesa.

Fuentes y referencias