Tests unitarios en Android con Mockk y Truth (III): Tests que implican corrutinas, LiveData y Flow

Introducción

En los artículos anteriores se explicó cómo comenzar a escribir tests unitarios y algunas funciones más avanzadas de Mockk. Una vez aprendido esto, es necesario pasar a hacer tests de cosas un poco más complejas, especialmente cuando vamos a testear clases de tipo ViewModel. En este artículo se recogen todos los "trucos" necesarios para conseguirlo.

Testeando corrutinas

Cuando estamos haciendo uso de un ViewModel, es normal que muchas de las funciones que tiene sean algo parecido a esto:

fun doSomething() = viewModelScope.launch {
...
}

Nota: si quieres conocer más sobre corrutinas, scopes, etc., puedes empezar por aquí.

Para comenzar, intentar llamar a funciones suspend dentro de un test normal va a dar un error en tiempo de compilación, puesto que el test debería ser una suspend function también. Para ello tenemos una keyword nueva, runTest. Veamos como se utiliza:

@Test
fun testSomething() = runTest {
// your test that uses coroutines here
}

Con esto, estamos creando un scope para ejecutar el test, por lo que ya podemos utilizar suspend functions dentro del mismo. No obstante, si intentamos testear funciones que hagan uso de viewModelScope.launch { ... } es posible que nos encontremos con un error diciéndonos algo de los dispatchers. Para ello, tenemos que cambiar el Dispatcher, lo cual se hace así:

@Before
fun setUp() {
    Dispatchers.setMain(UnconfinedTestDispatcher())
    // all your other initialization code here
}

@After
fun tearDown() {
    Dispatchers.resetMain()
    // all your other cleanup code here
}

@Test
fun testSomething() = runTest {
    // call your test here
}

Con esto, podríamos escribir tests para las funciones de nuestros ViewModels que utilicen la construcción viewModelScope.launch { ... }.

Nota: si quieres saber más sobre el tema de los Dispatchers, las alternativas a UnconfinedTestDispatcher, etc., puedes visitar este artículo.

Testeando valores de LiveData

Aunque parece que la idea es irlos reemplazando por los Flows, que son propios de Kotlin y no hacen uso del framework de Android; es posible que tu aplicación siga utilizando LiveData en los ViewModels para cambiar cosas de la UI. Si es así, puedes encontrarte con condiciones de carrera a la hora de pedir los valores del LiveData para comprobar su resultado.

Por suerte, no es nada que no se pueda solucionar, y para ello vamos a escribir la siguiente función de extensión:

import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException


@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun <T> LiveData<T>.getOrAwaitValue(
        time: Long = 2,
        timeUnit: TimeUnit = TimeUnit.SECONDS,
        afterObserve: () -> Unit = {}
): T {
    var data: T? = null
    val latch = CountDownLatch(1)
    val observer = object : Observer<T> {
        override fun onChanged(o: T?) {
            data = o
            latch.countDown()
            this@getOrAwaitValue.removeObserver(this)
        }
    }
    this.observeForever(observer)

    try {
        afterObserve.invoke()

        // Don't wait indefinitely if the LiveData is not set.
        if (!latch.await(time, timeUnit)) {
            throw TimeoutException("LiveData value was never set.")
        }

    } finally {
        this.removeObserver(observer)
    }

    @Suppress("UNCHECKED_CAST")
    return data as T
}

Básicamente, lo que hace esta función es que podamos pedir a un LiveData un valor, esperando hasta 2 segundos hasta que cambie. Si el valor no cambia, el método lanza una excepción, y, si cambia, retorna el valor.

Podemos llamar al método así:

@Test
fun testSomething() = runTest {
    // call your method that changes some live data value
    val result = yourLiveData.getOrAwaitValue()
    assertThat(result).isEqualTo(5)
}

Sin embargo, si hacemos esto tal cual, vamos a encontrarnos con que se provoca una excepción en el código. Esto es porque nos falta añadir una regla, que no es más que un par de líneas de código al principio de la clase de test.

Veamos un ejemplo. Si tuviéramos una vista que muestra los detalles de un coche, con una luz en la UI que se enciende cuando el motor está encendido, bindeada a un LiveData<Boolean> y quisiéramos testear su comportamiento, haríamos lo siguiente:

class CarDetailViewModelTest {

    @get:Rule
    val rule = InstantTaskExecutorRule()

    private lateinit var viewModel: CarDetailViewModel

    @Before
    fun setUp() {
        viewModel = CarDetailViewModel()
    }

    @After
    fun tearDown() {
        unmockkAll()
    }

    @Test
    fun `Turn on engine sets the isOn LiveData to true`() = runTest {
        viewModel.turnOnEngine()
        val liveDataValue = viewModel.isOn.getOrAwaitValue()
        assertThat(liveDataValue).isTrue()
    }
}

Las líneas que nos interesan son estas:

@get:Rule
val rule = InstantTaskExecutorRule()

Con esto ya podríamos realizar tests sobre variables de tipo LiveData.

Nota para MediatorLiveData

Si utilizas MediatorLiveData para combinar valores de diferentes MutableLiveData, verás que tus tests fallan. Esto es porque un MediatorLiveData nunca se actualiza si no hay nadie observándolo, por lo que necesitamos antes de nada crear un observer en el test:

@Test
fun testSomething() = runTest {
    // Arrange
    viewModel.myMediatorLiveData.observeForever {}
    // Update the mediator live data sources

    // Act

    // Assert
}

Una vez hecho esto ya podremos gestionar el valor del MediatorLiveData.

Testeando Flows

Para terminar este artículo, vamos a ver otro componente típico sobre el que podemos realizar tests: los Flows. Por ejemplo, un caso típico de uso es la utilización de SharedFlow para enviar eventos a la UI, o la utilización de StateFlow en lugar de LiveData.

Nota: si quieres saber más sobre SharedFlow y StateFlow, puedes ver el siguiente artículo.

Para poder testear Flows, vamos a necesitar de una nueva librería, llamada Turbine. La podemos añadir como haríamos con cualquier otra librería, en el build.gradle del módulo de la aplicación:

testImplementation 'app.cash.turbine:turbine:0.9.0'

Lo que hace esta librería es añadir una función de extensión a los Flows, llamada test, y algunas funciones más para esperar valores, etc.

Vamos a ver con un ejemplo cómo comprobar que se emitieron valores en un SharedFlow (utilizando el mismo ejemplo que veíamos en la sección del LiveData):

@Test
fun `Turn on engine emits true in SharedFlow`() = runTest {
    viewModel.isOn.test {
        viewModel.turnOnEngine()
        val item = awaitItem()
        assertThat(item).isTrue()
    }
}

Una cosa importante que hay que destacar es que si nunca se emite un item, el test no va a fallar, sino que se va a quedar colgado. Para esto, podemos pasarle un argumento a runTest, llamado dispatchTimeoutMs, que hará que si no se completa el test en esa cantidad de tiempo, falle. El único cambio quedaría así:

@Test
fun `Turn on engine emits true in SharedFlow`() = runTest(dispatchTimeoutMs = 50) {
    viewModel.isOn.test {
        viewModel.turnOnEngine()
        val item = awaitItem()
        assertThat(item).isTrue()
    }
}