Tests unitarios en Android con Mockk y Truth (I): Fundamentos

Introducción

En este artículo se explica cómo comenzar a escribir tests para aplicaciones de Android mediante el uso de los frameworks Mockk y Truth.

Primeros pasos

Lo primero de todo es añadir las dependencias al fichero build.gradle del módulo de la aplicación, como siempre:

testImplementation "io.mockk:mockk:1.12.3"
testImplementation 'junit:junit:4.13.2'
testImplementation "com.google.truth:truth:1.1.3"
testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.1'

Vamos a suponer que tenemos una clase Car y una clase Engine, de forma que la relación entre ellas es que todo coche tiene un motor. Por tanto, la clase Car podemos decir que depende de la clase Engine.

Para comenzar a testear, simplemente creamos en Android Studio una nueva clase dentro de la carpeta de tests (ojo, no de androidTest, puesto que esta se utiliza para los tests de instrumentación).

Nota: si es la primera vez que escribes tests en Android, puedes echar un vistazo a esta documentación.

Ahora sí, vamos a ver el código:

class CarTest {
    lateinit var engine: Engine
    lateinit var car: Car

    @Before
    fun setUp(){
        engine = mockk<Engine>() 
        car = Car (engine)
    }

    @After
    fun tearDown(){
        unmockkAll()
    }

    @Test
    fun `Car with broken engine throws exception when turning on`() {
        every { engine.turnOn() } throws BrokenEngineException()
        assertThrows(BrokenEngineException::class.java) { car.turnOn() } 
    }
}

En la siguiente sección se explica el código en detalle.

Estructura de los tests

Las funciones setUp y tearDown

Las dos primeras funciones que nos encontramos son estas, que además tienen las anotaciones @Before y @After. Esto ya nos indica un poco la naturaleza de las mismas, que es simplemente que se ejecutan antes y después de cada test, respectivamente.

Normalmente, en la función setUp lo que vamos a hacer es inicializar nuestras variables y lo que queremos testear (a partir de ahora, lo vamos a llamar SUT: subject under test). Como se puede observar, las dependencias las vamos a inicializar como mocks, es decir, objetos "tontos" que van a adquirir el comportamiento que les digamos (más abajo se explica cómo). El SUT lo vamos a inicializar con el objeto de verdad, puesto que lo que queremos testear es el objeto de verdad.

En cuanto a la función tearDown no tiene mucha historia, en este caso quitamos todos los mocks para limpiar y listo. En esta función se realizan las labores de limpieza.

La definición del test

Cada test unitario va a ser simplemente una función que va a tener la etiqueta @Test y un nombre describiendo lo que hace.

En cuanto a la forma de escribir los nombres, hay muchas, pero en general en Kotlin se escribe entre dos backticks con una frase descriptiva en inglés. La frase suele describir el SUT, las precondiciones y el resultado esperado.

En el caso del ejemplo, estamos "asegurando" que todo coche (SUT) con un motor roto (precondiciones) lanzará una excepción cuando se encienda (resultado esperado).

Los tests unitarios se suelen dividir en tres secciones (en general, no es algo exclusivo de Kotlin ni de Android), conocidas como la triple A: Arrange, Act y Assert, que traducido al español viene a ser algo como organizar los prerrequisitos, actuar y afirmar el resultado.

Arrange: definiendo el comportamiento de nuestros mocks

Entrando en el cuerpo del método, la primera parte que tenemos que escribir es la de organizar los prerrequisitos. Esto viene a ser sobre todo establecer el comportamiento de nuestros mocks. Como son objetos tontos, si intentamos llamar a alguna función (por ejemplo, en el caso de Engine, encender el motor), nos va a saltar una excepción porque no hemos definido qué tiene que pasar cuando se llame a esa función en el mock.

Para esto se usan generalmente las funciones every y coEvery, dependiendo de si el método que vamos a mockear hace uso de corrutinas o no. La sintaxis es exactamente la misma, así que vamos a ver los ejemplos utilizando every:

every { engine.turnOff() } just runs
every { engine.turnOn() } throws BrokenEngineException()
every { engine.getHorsePower() } returns 100

Principalmente los tres métodos que vamos a utilizar son estos.

  • El primero se utiliza para métodos que devuelven Unit, es decir, métodos que no retornan nada, para que al llamarlos no hagan nada pero no lancen una excepción.
  • El segundo se utiliza para que cuando se llame al método, este lance la excepción que le decimos.
  • El último se utiliza para métodos que devuelven algún valor.

Act: llamando a la función del SUT

El segundo paso sería realizar la llamada al método del SUT. Simplemente, cuando ya hemos establecido todas las precondiciones, haríamos una llamada normal al método que queramos probar y listo. Debido a la sintaxis del framework Truth, esto se hace muchas veces en conjunto con el último paso, por lo que no voy a destacar nada más.

Assert: comprobando el resultado

Por último tendríamos que comprobar el resultado. Esto puede ser realmente variado, puesto que el resultado de una ejecución que queremos comprobar pueden ser de cualquier naturaleza. Normalmente, vamos a querer comprobar alguna de estas cosas:

  • Que se llama a cierta función.
  • Que no se llama a cierta función.
  • Que el valor de retorno de una función coincide con otro resultado esperado.
  • Que una función lanza una excepción.
  • Que el objeto devuelto por una función es de un tipo concreto.

Vamos a ver cómo podríamos comprobar estas cosas utilizando los frameworks que tenemos disponibles:

verify { engine.hasEnoughFuel() }
verify(exactly = 0) { engine.hasEnoughFuel() }

En este primer ejemplo, lo que estamos comprobando en el primer caso es que la función se llama, sin especificar cuántas veces. Si se llama al menos una vez, el test pasará, y si no, fallará. En el segundo ejemplo, lo que estamos haciendo es lo contrario, es decir, comprobar que la función no se llama. De esta forma, el test pasará si la función no se llama, y fallará si se llama al menos una vez.

Nota: al igual que existía coEvery para mockear, existe coVerify para comprobar funciones que utilizan corrutinas.

assertThat(car.getHorsePower()).isEqualTo(100)
assertThat(car.hasEnoughFuel()).isTrue()

En esta segunda familia de ejemplos, lo que estamos comprobando es que el valor de retorno de llamar a cierta función es igual a otro valor que esperamos.

assertThrows(BrokenEngineException::class.java) { car.turnOn() }

Este tercer ejemplo comprobaría que el método lanza una excepción del tipo BrokenEngineException cuando se llama al método turnOn de la clase Car.

assertThat(car.getEngineType()).isInstanceOf(DieselEngine::class.java)

Por último, esto comprobaría que el motor del coche es diésel. De esta forma, si fuese un motor de gasolina, híbrido o eléctrico, el test no pasaría.

Conclusiones

Esto debería ser una buena primera toma de contacto con el mundo del testing en Android en la actualidad. Estos frameworks extienden mucho la funcionalidad de JUnit, que es otro de los frameworks más utilizados, y simplifican mucho la sintaxis de los tests.

En otros artículos de esta serie se explicará cómo escribir tests más complejos, testear objetos de tipo LiveData, objetos de tipo Flow, corrutinas, etc.