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á
|
Text
|
|
|---|---|
|
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
Textkotlin/src
:
- com.autentia.domain: Clases del dominio
TextTask
.
- com.autentia.repository: Repositorios de entidades
TextTaskRepository
.
- com.autentia.usecase: Implementaciones de casos de uso del dominio.
- com.autentia.domain: Clases del dominio
- En
Textkotlin/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
|
Text
|
|
|---|---|
|
test
|
|
a nuestro gestor de dependencias, en nuestro caso como hijo del elemento
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
CreateTaskUseCase
|
|
y su correspondiente test unitario
|
Text
|
|
|---|---|
|
CreateTaskUseCaseTest
|
|
.
Identificamos rápidamente que vamos a necesitar añadir el repositorio de tareas
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
save
|
|
de
|
Text
|
|
|---|---|
|
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
TextTaskRepository
a través del método
Textmockk()en modo estricto.
- Utilizando
Textevery
se establece que para cada vez que se ejecute el método
TextsaveTaskse devuelva
TextUnit(
Textvoiden Kotlin) y no haga nada.
- Utilizando
Textverify
, se establece que el método
TextsaveTaskse debe llamar exactamente una vez
Text(exactly=1).
Se ha establecido el mock en modo estricto. Esto implica que se debe proporcionar comportamiento al método
|
Text
|
|
|---|---|
|
saveTask
|
|
, de otro modo el test fallará con una excepción similar a esta:
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
Unit
|
|
con
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
Unit
|
|
, en vez de usar
|
Text
|
|
|---|---|
|
every
|
|
se puede utilizar
|
Text
|
|
|---|---|
|
justRun
|
|
con el método en cuestión. Por ejemplo
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
CheckAllExpiredUseCase
|
|
y su correspondiente test
|
Text
|
|
|---|---|
|
CheckAllExpiredUseCaseTest
|
|
.
class CheckAllExpiredTasksUseCase(val taskRepository: TaskRepository)
Se utiliza nuevamente
|
Text
|
|
|---|---|
|
mockk
|
|
para mockear la dependencia con
|
Text
|
|
|---|---|
|
TaskRepository
|
|
. Esta vez, se proporciona un comportamiento a través de
|
Text
|
|
|---|---|
|
every
|
|
y
|
Text
|
|
|---|---|
|
justRun
|
|
para establecer que cada vez que se llame al método
|
Text
|
|
|---|---|
|
findAllTasks
|
|
se devuelva una lista con una tarea pendiente y con otra expirada, además de proporcionar comportamiento vacío a
|
Text
|
|
|---|---|
|
updateTask
|
|
. Entre otras cosas, en este test se define con
|
Text
|
|
|---|---|
|
verifyAll
|
|
que los métodos
|
Text
|
|
|---|---|
|
findAllTasks
|
|
y
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
saveTask
|
|
, el cual en nuestro dominio implica la creación de una nueva tarea. Para conseguirlo, podemos marcar la creación del mock como
|
Text
|
|
|---|---|
|
relaxedUnitFun
|
|
y verificamos que
|
Text
|
|
|---|---|
|
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
|
Text
|
|
|---|---|
|
every
|
|
de llamadas a
|
Text
|
|
|---|---|
|
mockk
|
|
se proporciona un comportamiento similar de ambas instancias de
|
Text
|
|
|---|---|
|
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
Textmockk
y los diferentes modos de uso que proporciona.
- Hemos aprendido a utilizar
Textevery
para proporcionar comportamiento a los mocks.
- Hemos aprendido a utilizar
Textverify
para establecer las condiciones de uso de los métodos de una clase mockeada.
- Hemos aprendido a utilizar
TextverifyAll
y
TextverifyOrderpara 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/