3.2.2.-MockK
MockK: una biblioteca burlona para Kotlin¶
1. Información general¶
Vamos a revisar algunas de las funciones básicas de la biblioteca MockK.
2. MockK¶
En Kotlin, todas las clases y métodos son finales. Si bien esto nos ayuda a escribir código inmutable, también causa algunos problemas durante las pruebas.
La mayoría de las bibliotecas que simulan objetos (mock) que se usan en JVM tienen problemas con las clases finales usadas para mocking or stubbing. Por supuesto, podemos agregar la palabra clave open
a las clases y métodos que queremos "mockear" (simular). Pero cambiar el código solo para poder mockearla no parece el mejor enfoque.
La biblioteca MockK ofrece compatibilidad con las características y construcciones del lenguaje Kotlin. MockK construye proxies para las clases mockeadas. Esto provoca cierta degradación del rendimiento, pero los beneficios generales que nos brinda MockK valen la pena.
3. Instalación¶
La configuración del proyecto consiste en añadir las dependencias de mockk al fichero build.gradle.kts
Ten en cuenta que estamos estableciendo la version 1.13.4
, en el repositorio Maven puedes consultar cuál es la última versión.
4. Ejemplo básico¶
Vamos a crear un servicio que podremos usar como ejemplo para "mockear" (simular) su funcionamiento:
class TestableService {
fun getDataFromDb(testParameter: String): String {
// query database and return matching value
}
fun doSomethingElse(testParameter: String): String {
return "I don't want to!"
}
}
Aquí hay un test de ejemplo que mockea TestableService
:
@Test
fun givenServiceMock_whenCallingMockedMethod_thenCorrectlyVerified() {
// given
val service = mockk<TestableService>()
every { service.getDataFromDb("Expected Param") } returns "Expected Output"
// when
val result = service.getDataFromDb("Expected Param")
// then
verify { service.getDataFromDb("Expected Param") }
assertEquals("Expected Output", result)
}
- Para definir el objeto mockeado
TestableService
, hemos usado el métodomockk<…>()
. - En el siguiente paso, definimos el comportamiento de nuestro objeto mockeado
TestableService
. Para este propósito, hemos creado un bloqueevery
que describe qué respuesta debe devolversereturns "Expected"
para qué llamada y con qué argumento. Es decir, el resultado que devolverá cuando se llame al métodoservice.getDataFromDb
y con un argumento concreto("Expected Param")
- Finalmente, usamos el bloque de
verify
para verificar si el simulacro se invocó como esperábamos, siendo verificado si al menos se ha invocado una vez el métodoservice.getDataFromDb("Expected Param")
.
5. Ejemplo de uso de las anotaciones¶
Es posible usar las anotaciones MockK @MockK
para crear todo tipo de objetos mocks. Vamos a crear un servicio que requiera de dos instancias de nuestro TestableService
:
class InjectTestService {
lateinit var service1: TestableService
lateinit var service2: TestableService
fun invokeService1(): String {
return service1.getDataFromDb("Test Param")
}
}
El servicio que hemos creado, InjectTestService
, contiene dos variables de instancia del mismo tipo. Esto no será un problema para MockK, ya que MockK intenta hacer coincidir las propiedades por nombre y luego por clase o superclase. Además, tampoco tiene problema con la inyección de objetos en campos privados.
Vamos a mockear el servicio InjectTestService
en el siguiente test haciendo uso de las anotaciones:
class AnnotationMockKUnitTest {
@MockK
lateinit var service1: TestableService
@MockK
lateinit var service2: TestableService
@InjectMockKs
var objectUnderTest = InjectTestService()
@BeforeEach
fun setUp() = MockKAnnotations.init(this)
// Tests here
...
}
Revisemos lo más importante del código anterior:
- Con la anotación
@MoockK
, hemos definido (marcdo para crear) los objetos mocksservice1
yservice2
que vamos a inyectar en el servicioobjectUnderTest
. - Hemos usado la anotación
@InjectMockKs
para especificar el objetoobjectUnderTest
donde se deben inyectar los objetos mocks definidos. - Por defecto, los objetos mocks se inyectan en variables que aún no están asignadas. Aunque, podemos usar
@OverrideMockKs
para anular campos que ya tienen un valor. - Por último, MockK requiere que se llame a
MockKAnnotations.init(…)
en el objeto que declara variables con anotaciones. Para Junit5, se puede reemplazar con@ExtendWith(MockKExtension::class)
.
6. Spy¶
Spy
permite en un objeto mezclar el comportamiento de objetos reales y mockeados, es decir, mockear solo una parte particular de alguna clase. Por ejemplo, se puede usar para simular un método específico del servicio TestableService
, el resto del servicio funcionará como se definió. Si no se define un comportamiento nuevo, el objeto entero funcionara como se definió en su clases, pero podremos usar sobre él verificaciones haciendo uso de verify
. Veamos el ejemplo:
@Test
fun givenServiceSpy_whenMockingOnlyOneMethod_thenOtherMethodsShouldBehaveAsOriginalObject() {
// given
val service = spyk<TestableService>()
every { service.getDataFromDb(any()) } returns "Mocked Output"
// when checking mocked method
val firstResult = service.getDataFromDb("Any Param")
// then
assertEquals("Mocked Output", firstResult)
// when checking not mocked method
val secondResult = service.doSomethingElse("Any Param")
// then
assertEquals("I don't want to!", secondResult)
}
Revisemos lo más importante del código anterior:
- Hemos usado el método
spyk
para crear un objeto spyk (espía) de la claseTestableService
. - También hemos usado
every
para definir el comportamiento del método que nos interesa, en concretoservice.getDataFromDb(any())
, como vimos en ejemplos anteriores.
También podemos haber usado la anotación @SpyK
para lograr lo mismo que hicimos con la anotación @MockK
. Veamos un ejemplo:
7. Mockeado relajado¶
Un objeto mock típico lanzará la excepción MockKException
si intentamos llamar a un método donde no se ha especificado el valor de retorno, es decir, en donde no hayamos definido un comportamiento simulado.
Si no queremos describir el comportamiento de cada método, podemos usar un proceso de mockeado relajado. Este tipo de mockeado proporciona valores predeterminados para cada función. Por ejemplo, el tipo de retorno String
devolverá un String
vacío. He aquí un breve ejemplo:
@Test
fun givenRelaxedMock_whenCallingNotMockedMethod_thenReturnDefaultValue() {
// given
val service = mockk<TestableService>(relaxed = true)
// when
val result = service.getDataFromDb("Any Param")
// then
assertEquals("", result)
}
En el ejemplo, hemos usado el método mockk
con el argumento relaxed = True
para crear un objeto mockeado relajado. También podríamos haber usado la anotación @RelaxedMockK
:
8. Mockeado de objetos¶
Kotlin proporciona una manera fácil de declarar un singleton usando la palabra clave object
:
object TestableService {
fun getDataFromDb(testParameter: String): String {
// query database and return matching value
}
}
La mayoría de las bibliotecas para crear objetos mocks tienen un problema con el mockeado las instancias de objetos singleton de Kotlin. Para resolver esto, MockK proporciona el método mockkObject
. Un ejemplo de uso:
@Test
fun givenObject_whenMockingIt_thenMockedMethodShouldReturnProperValue(){
// given
mockkObject(TestableService)
// when calling not mocked method
val firstResult = service.getDataFromDb("Any Param")
// then return real response
assertEquals(/* DB result */, firstResult)
// when calling mocked method
every { service.getDataFromDb(any()) } returns "Mocked Output"
val secondResult = service.getDataFromDb("Any Param")
// then return mocked response
assertEquals("Mocked Output", secondResult)
}
9. Mockeado jerárquico¶
Otra característica útil de MockK es la capacidad de mockear objetos jerárquicos. Parar entenderlo, primero, creemos una estructura de objeto jerárquica:
class Foo {
lateinit var name: String
lateinit var bar: Bar
}
class Bar {
lateinit var nickname: String
}
La clase Foo
contiene un campo de tipo Bar
. Ahora, podemos mockear la estructura en un solo paso. Vamos a mockear los campos name
y bar
:
@Test
fun givenHierarchicalClass_whenMockingIt_thenReturnProperValue() {
// given
val foo = mockk<Foo> {
every { name } returns "Karol"
every { bar } returns mockk {
every { nickname } returns "Tomato"
}
}
// when
val name = foo.name
val nickname = foo.bar.nickname
// then
assertEquals("Karol", name)
assertEquals("Tomato", nickname)
}
10. Captura de argumentos¶
Si necesitamos capturar los valores de los parámetros pasados a un método, podemos usar CapturingSlot
o MutableList
. Es útil cuando queremos tener alguna lógica personalizada en un bloque answers
o simplemente necesitamos verificar el valor de los parámetros pasados. Aquí hay un ejemplo de CapturingSlot
:
@Test
fun givenMock_whenCapturingParamValue_thenProperValueShouldBeCaptured() {
// given
val service = mockk<TestableService>()
val slot = slot<String>()
every { service.getDataFromDb(capture(slot)) } returns "Expected Output"
// when
service.getDataFromDb("Expected Param")
// then
assertEquals("Expected Param", slot.captured)
}Copiar
MutableList
se puede usar para capturar y almacenar todos los valores tomados por el parámetro en las distintas invocaciones del método:
@Test
fun givenMock_whenCapturingParamsValues_thenProperValuesShouldBeCaptured() {
// given
val service = mockk<TestableService>()
val list = mutableListOf<String>()
every { service.getDataFromDb(capture(list)) } returns "Expected Output"
// when
service.getDataFromDb("Expected Param 1")
service.getDataFromDb("Expected Param 2")
// then
assertEquals(2, list.size)
assertEquals("Expected Param 1", list[0])
assertEquals("Expected Param 2", list[1])
}
11. Funciones Stubbing que retornan Unit¶
Un stub es, en el contexto del testeo del software, un trozo de código usado como sustituto de alguna otra funcionalidad. Un stub puede simular el comportamiento de código existente (tal como un procedimiento en una máquina remota) o ser el sustituto temporal para un código aún no desarrollado. Los stubs son, por tanto, muy útiles para porting, computación distribuida así como en el desarrollo y pruebas de software en general.
En Kotlin, si el tipo de retorno de una función es Unit
, significa que la función no devuelve nada. Es bastante similar al método void
de Java.
Para ejemplificarlo, agreguemos una función que retorna Unit
a la clase TestableService
:
fun addHelloWorld(strList: MutableList<String>) {
println("addHelloWorld() is called")
strList += "Hello World!"
}
Como muestra el código anterior, cuando se llama a la función addHelloWorld()
, imprime una línea en la consola. Luego, el String
"Hello World"
se agrega al objeto MutableList<String>
recibido por parámetro.
En esta sección, vamos a ver cómo hacer un stub de una función que devuelve Unit
. Por lo general, dependiendo de diferentes circunstancias, queremos controlar un stub de una función que retorna Unit
de dos maneras:
- hacer que la llamada a la función no haga nada, o en otras palabras, omitir la ejecución de la función
- llamando a la función real
A continuación, usaremos la función addHelloWorld()
como ejemplo y veremos cómo lograrlo usando MockK.
11.1. Hacer que la función no haga nada¶
Hay varias formas de omitir la ejecución de una función que retorna Unit
usando MockK:
every { addHelloWorld(any()) } returns Unit
every { addHelloWorld(any()) } answers { Unit }
every { addHelloWorld(any()) } just runs
Algunas de ellas no son nuevas para nosotros
- returns …
- answers {…}
,
- y el último, every {... } just runs
es fácil de entender.
Entonces, primero probemos si se puede omitir la ejecución de la función original y luego entendamos cómo funciona just runs
:
@Test
fun givenServiceMock_whenCallingMethodReturnsUnit_thenCorrectlyVerified() {
// given
val service = mockk<TestableService>()
val myList = mutableListOf<String>()
// when
every { service.addHelloWorld(any()) } just runs
service.addHelloWorld(myList)
// then
assertTrue(myList.isEmpty())
}
Como muestra el código anterior:
- Agregamos la función
addHelloWorld()
usandojust runs
. - Luego, llamamos a la función y le pasamos una
MutableList
vacía. - Por último, si hemos conseguido omitir la ejecución de la función con éxito, después de la invocación de la función, la cadena
“Hello World!”
no debe aparecer en la lista pasada como parámetro a la función, y por tanto el test deberá dar ok.
Ahora entendamos cómo just runs
omite la llamada de la función real. Primero, echemos un vistazo a la implementación de la función just()
:
Como podemos ver, just
es una función infix
(infija). Por lo tanto, podemos escribir just(runs)
como una forma mas fácil de leer:just runs
. Además, Runs
es un objeto dummy, y runs
es simplemente un typealias
de Runs
:
Por último, si creamos un stub de una función que retorna Unit
como just runs
, se llama a la función answers()
y retorna una constante: Unit
.
11.2. Llamar a la función original¶
Ahora, veamos cómo crear un stub de una función que retorna Unit
para hacer que llame a la función real. Para llamar a la función original, podemos usar este enfoque: every { … } answers { callOriginal() }
A continuación, veamos cómo se usa en una prueba real:
@Test
fun givenServiceMock_whenCallingOriginalMethod_thenCorrectlyVerified() {
// given
val service = mockk<TestableService>()
val myList = mutableListOf<String>()
// when
every { service.addHelloWorld(any()) } answers { callOriginal() }
service.addHelloWorld(myList)
// then
assertEquals(1, myList.size)
assertEquals("Hello World!", myList.first())
}
El test es ok tras de ejecutarlo. Entonces, “Hello World!”
se agrega a myList
después de llamar a la función. Sin embargo, podemos preguntarnos: si creamos un stub de una función y le pedimos que llame a la implementación original, ¿por qué nos molestamos en crear el stub?
A continuación, veamos un ejemplo de cuándo es útil.
11.3. ¿Cuándo necesitamos callOriginal()
?¶
Digamos que en nuestra prueba, queremos llamar a la función addHelloWorld()
con diferentes parámetros. Si la lista que se le pasa a la función contiene la cadena "Kai"
, queremos llamar a la función real. De lo contrario, nos gustaría omitir la llamada a la función:
@Test
fun givenServiceMock_whenStubbingTwoScenarios_thenCorrectlyVerified() {
// given
val service = mockk<TestableService>()
val kaiList = mutableListOf("Kai")
val emptyList = mutableListOf<String>()
// when
every { service.addHelloWorld(any()) } just runs
every { service.addHelloWorld(match { "Kai" in it }) } answers { callOriginal() }
service.addHelloWorld(kaiList)
service.addHelloWorld(emptyList)
// then
assertEquals(listOf("Kai", "Hello World!"), kaiList)
assertTrue(emptyList.isEmpty())
}
Como podemos ver en el código anterior, agregamos la función addHelloWorld()
dos veces, según nuestros requisitos. Por lo tanto, callOriginal()
nos permite decidir el comportamiento de los stubings de forma flexible.
12. Conclusión¶
Se han visto algunas de las características más importantes de MockK. MockK es una poderosa biblioteca para el lenguaje Kotlin y proporciona muchas características útiles. Para más información sobre MockK, podemos consultar la documentación en la web de MockK.
Recursos¶
- ¿Que es un Stub?
- Mock Object
- Stub, Spy, Mock
- ¿Qué es TDD?
- ¿Cómo elaborar casos de prueba?
- Test unitario avanzado
- Diseño de Software