Configuración y uso básico de mockk para Kotlin

0
1453
Portada del artículo

1 Introducción

En el mundo de Kotlin existen diversas librerías para realizar mocking y stubbing a la hora de realizar pruebas en el software. Al ser interoperable con Java, librerías como mockito suelen ser utilizadas dentro de Kotlin.

Sin embargo, Kotlin dispone de alternativas nativas (implementadas en el propio lenguaje y no en Java) que son tanto implementaciones propias como adaptaciones de librerías existentes. Por ejemplo, Mockito tiene su propia versión nativa oficial denominada mockito-kotlin.

En este tutorial vamos a descubrir mockk, la cual es la librería nativa de mocking en Kotlin con mayor repercusión en Github (4.4k estrellas), la cual parece erigirse como librería de referencia en Kotlin. Es utilizado en la guía oficial de Spring-boot en Kotlin excluyendo a la propia mockito.

1.1 Configuración del entorno

Para la configuración de Kotlin y Maven revisa el archivo pom.xml del repositorio que se enlaza al final de este artículo. El entorno utilizado para desarrollar el artículo es el siguiente:

  • Open JDK 17
  • Kotlin 1.6.20
  • Maven 3.8.5
  • IntelliJ IDEA 2021.3.3 (Ultimate Edition) como entorno de desarrollo

1.2 Dominio del problema

Se ha creado un pequeño dominio para ilustrar el uso de la librería. La clase de dominio principal será Task y representa una Tarea, que contiene una descripción, una fecha de expiración y un «check» de realizada.

// Definición de tarea
class Task(
    val text: String,
    val expirationDate: LocalDate = LocalDate.now().plusDays(7),
    var checked: Boolean = false
)

// Repositorio de tareas
interface TaskRepository {
    fun saveTask(task: Task)
    fun updateTask(task: Task)
    fun findAllTasks(): List
}

En el repositorio, el código está organizado de la siguiente manera:

  • En kotlin/src:
    • com.autentia.domain: Clases del dominio Task.
    • com.autentia.repository: Repositorios de entidades TaskRepository.
    • com.autentia.usecase: Implementaciones de casos de uso del dominio.
  • En kotlin/test
    • com.autentia: Test unitarios

2 Uso de mockk

En este apartado utilizaremos algunas de las opciones que proporciona mockk para implementar los test de los casos de uso. Para conocer todas las opciones es aconsejable visitar su documentación oficial.

2.1 Configuración

Para añadir mockk a nuestro proyecto es suficiente con añadir la dependencia con ámbito test a nuestro gestor de dependencias, en nuestro caso como hijo del elemento dependencies en pom.xml de la siguiente manera:

<!-- En el momento de hacer este tutorial la versión es la 1.12.3 -->
<dependency>
    <groupId>io.mockk</groupId>
    <artifactId>mockk</artifactId>
    <version>${mockk.version}</version>
    <scope>test</scope>
</dependency>

2.2 Caso de uso: Creación de una tarea

Vamos a comenzar creando el primer caso de uso de nuestro dominio, el cual trata de añadir una tarea al repositorio de tareas. Para ello crearemos la clase CreateTaskUseCase y su correspondiente test unitario CreateTaskUseCaseTest.

Identificamos rápidamente que vamos a necesitar añadir el repositorio de tareas TaskRepository como dependencia del caso de uso, por lo que lo añadimos a la clase. Esto va a permitir añadir la dependencia mockeada al sujeto de la prueba, que será el caso de uso.

Simplificando, nuestra clase del caso de uso quedaría de la siguiente manera:

 class CreateTaskUseCase(val taskRepository: TaskRepository)

A continuación, en nuestro test vamos a utilizar mockk para mockear la dependencia con el repositorio y para verificar que en el caso de uso se llama al método save de TaskRepository solo una vez con un objeto de tipo Task.

@Test
fun `use case should create task`() {
    val taskRepository = mockk()
    every { taskRepository.saveTask(any()) } returns Unit

    val taskText = "Realizar tutorial de mockk en adictosaltrabajo.com"

    val usecase = CreateTaskUseCase(taskRepository)
    val request = CreateTaskRequest(taskText)
    val createdTask = usecase.executeUseCase(request)

    verify(exactly = 1) { taskRepository.saveTask(any()) }
    assert(createdTask.text == taskText)
}

En cuanto a mocks, en este test:

  • Se define el mock de TaskRepository a través del método mockk() en modo estricto.
  • Utilizando every se establece que para cada vez que se ejecute el método saveTask se devuelva Unit (void en Kotlin) y no haga nada.
  • Utilizando verify, se establece que el método saveTask se debe llamar exactamente una vez (exactly=1).

Se ha establecido el mock en modo estricto. Esto implica que se debe proporcionar comportamiento al método saveTask, de otro modo el test fallará con una excepción similar a esta: io.mockk.MockKException: no answer found for: TaskRepository(#1).saveTask(com.autentia.domain.Task@7ee3d262)

El test falla al no proporcionar comportamiento al mock:

val taskRepository = mockk()
// every { taskRepository.saveTask(any()) } returns Unit
verify(exactly = 1) { taskRepository.saveTask(any()) }
// mockk no sabe qué hacer con el método saveTask

El test pasa sin proporcionar comportamiento explícitamente, aunque por defecto se establece un comportamiento «vacío»:

val taskRepository = mockk(relaxed = true)
// every { taskRepository.saveTask(any()) } returns Unit
verify(exactly = 1) { taskRepository.saveTask(any()) }
// mockk establece por defecto un comportamiento vacío para saveTask

Se podría afinar un poco más y hacer que mockk solo proporcione este comportamiento vacío por defecto a aquellos métodos que devuelvan el tipo Unit con mockk(relaxedUnitFun = true), mientras que seguiría necesitando proporcionar comportamiento a aquellos que devuelvan un tipo distinto.

NOTA: Si simplemente se quiere dar un comportamiento vacío y devolver Unit, en vez de usar every se puede utilizar justRun con el método en cuestión. Por ejemplo justRun { taskRepository.saveTask(any()) }

2.3 Caso de uso: Marcar tareas expiradas

Se va a implementar un caso de uso que consiste en marcar como completadas aquellas tareas expiradas del repositorio. Para ello creamos la clase CheckAllExpiredUseCase y su correspondiente test CheckAllExpiredUseCaseTest.

class CheckAllExpiredTasksUseCase(val taskRepository: TaskRepository)

Se utiliza nuevamente mockk para mockear la dependencia con TaskRepository. Esta vez, se proporciona un comportamiento a través de every y justRun para establecer que cada vez que se llame al método findAllTasks se devuelva una lista con una tarea pendiente y con otra expirada, además de proporcionar comportamiento vacío a updateTask. Entre otras cosas, en este test se define con verifyAll que los métodos findAllTasks y updateTask deben ser llamados.

@Test
fun `use case should check expired task`() {
    val taskRepository = mockk()

    val tareaPendiente = Task("Tarea pendiente", LocalDate.now().plusDays(1))
    val tareaExpirada = Task("Tarea expirada", LocalDate.now().minusDays(1))

    every { taskRepository.findAllTasks() } returns listOf(
        tareaPendiente, tareaExpirada
    )

    justRun { taskRepository.updateTask(any()) }

    val useCase = CheckAllExpiredTasksUseCase(taskRepository)

    useCase.executeUseCase()

    verifyAll {
        taskRepository.findAllTasks()
        taskRepository.updateTask(tareaExpirada)
    }
}

Este test se podría afinar mucho más haciendo uso de las diferentes opciones que proporciona mockk. Con este test no tendríamos garantía de que la actualización de la instancia de Task se ejecute después de la obtención. Por ello, se podría verificar que, además de llamarse todos los métodos en verifyAll, se llaman en el orden específico en el que se establecen:

verifyOrder {
    taskRepository.findAllTasks()
    taskRepository.updateTask(tareaExpirada)
}

Además, en este caso se puede establecer que nunca se llame al método saveTask, el cual en nuestro dominio implica la creación de una nueva tarea. Para conseguirlo, podemos marcar la creación del mock como relaxedUnitFun y verificamos que saveTask no fue llamado.

fun `use case should check expired task`() {
    val taskRepository = mockk(relaxedUnitFun = true)

    // se omite el resto por simplicidad

    verify(exactly = 0) {
        taskRepository.saveTask(any())
    }
}

La librería mockk también permite establecer una jerarquía de mocks que preparen el conjunto de datos que anteriormente hemos tenido que crear de manera concreta. Mediante la concatenación en every de llamadas a mockk se proporciona un comportamiento similar de ambas instancias de Task utilizadas en el ejemplo anterior. Recuerda que hay que proporcionar comportamiento a todos los métodos.

@Test
fun `use case should check expired task with mocks`() {
    val taskRepository = mockk()

    every { taskRepository.findAllTasks() } returns listOf(mockk {
        every { text } returns "Tarea pendiente"
        every { expirationDate } returns LocalDate.now().plusDays(1)
        every { isExpired() } returns false
        justRun { check() }
    }, mockk {
        every { text } returns "Tarea expirada"
        every { expirationDate } returns LocalDate.now().minusDays(1)
        every { isExpired() } returns true
        justRun { check() }
    })

    justRun { taskRepository.updateTask(any()) }

    val useCase = CheckAllExpiredTasksUseCase(taskRepository)

    useCase.executeUseCase()

    verifyOrder {
        taskRepository.findAllTasks()
        taskRepository.updateTask(any())
    }
}

3 Conclusiones

  • La librería mockk proporciona un mecanismo de creación de mocks que se integra perfectamente en el ecosistema Kotlin.
  • Hemos aprendido el uso del método mockk y los diferentes modos de uso que proporciona.
  • Hemos aprendido a utilizar every para proporcionar comportamiento a los mocks.
  • Hemos aprendido a utilizar verify para establecer las condiciones de uso de los métodos de una clase mockeada.
  • Hemos aprendido a utilizar verifyAll y verifyOrder para establecer condiciones de ejecución y de orden de
    ejecución.

Anexos

Referencias

DEJA UNA RESPUESTA

Por favor ingrese su comentario!

He leído y acepto la política de privacidad

Por favor ingrese su nombre aquí

Información básica acerca de la protección de datos

  • Responsable:
  • Finalidad:
  • Legitimación:
  • Destinatarios:
  • Derechos:
  • Más información: Puedes ampliar información acerca de la protección de datos en el siguiente enlace:política de privacidad