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.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
// 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.
- com.autentia.domain: Clases 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:
1 2 3 4 5 6 7 |
<!-- 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:
1 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@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étodomockk()
en modo estricto. - Utilizando
every
se establece que para cada vez que se ejecute el métodosaveTask
se devuelvaUnit
(void
en Kotlin) y no haga nada. - Utilizando
verify
, se establece que el métodosaveTask
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:
1 2 3 4 |
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»:
1 2 3 4 |
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
.
1 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
@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:
1 2 3 4 |
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.
1 2 3 4 5 6 7 8 9 |
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
@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
yverifyOrder
para establecer condiciones de ejecución y de orden de
ejecución.
Anexos
- En el siguiente repositorio se encuentra el código completo utilizado en este artículo: mariocalin/mockk-playground
- Guía de migración de Mockito a mockk: https://notwoods.github.io/mockk-guidebook/docs/mockito-migrate/
Referencias
- Sitio web oficial de mockk: https://mockk.io/