Cómo probar los flujos de Kotlin en Android

La forma en que pruebas las unidades o los módulos que se comunican con un flujo depende de si el sujeto de prueba usa el flujo como entrada o como salida.

  • Si el sujeto de prueba observa un flujo, puedes generar flujos dentro de dependencias falsas que puedes controlar en las pruebas.
  • Si la unidad o el módulo expone un flujo, puedes leer y verificar uno o varios elementos emitidos por el flujo en la prueba.

Cómo crear un productor falso

Cuando el sujeto de prueba es consumidor de un flujo, una forma común de probarlo es reemplazar el productor por una implementación falsa. Por ejemplo, en una determinada clase que observa un repositorio que toma datos de dos fuentes de datos en producción:

el sujeto de prueba y la capa de datos
Figura 1: El sujeto de prueba y la capa de datos.

Para que la prueba sea determinante, puedes reemplazar el repositorio y sus dependencias por un repositorio falso que siempre emita los mismos datos falsos:

reemplazo de las dependencias por una implementación falsa
Figura 2: Reemplazo de las dependencias por una implementación falsa.

Para emitir una serie predefinida de valores en un flujo, usa el compilador flow:

class MyFakeRepository : MyRepository {
    fun observeCount() = flow {
        emit(ITEM_1)
    }
}

En la prueba, se inserta este repositorio falso para reemplazar la implementación real:

@Test
fun myTest() {
    // Given a class with fake dependencies:
    val sut = MyUnitUnderTest(MyFakeRepository())
    // Trigger and verify
    ...
}

Ahora que tienes el control de los resultados del sujeto de prueba, puedes verificar que funcione correctamente revisando sus resultados.

Cómo confirmar las emisiones del flujo en una prueba

Si el sujeto de prueba expone un flujo, la prueba debe realizar aserciones sobre los elementos del flujo de datos.

Supongamos que el repositorio del ejemplo anterior expone un flujo:

repositorio con dependencias falsas que expone un flujo
Figura 3: Repositorio (sujeto de prueba) con dependencias falsas que expone un flujo.

Con ciertas pruebas, solo deberás verificar la primera emisión o una cantidad limitada de elementos provenientes del flujo.

Puedes consumir la primera emisión al flujo llamando a first(). Esta función espera hasta que se reciba el primer elemento y, luego, envía la señal de cancelación al productor.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository that combines values from two data sources:
    val repository = MyRepository(fakeSource1, fakeSource2)

    // When the repository emits a value
    val firstItem = repository.counter.first() // Returns the first item in the flow

    // Then check it's the expected item
    assertEquals(ITEM_1, firstItem)
}

Si la prueba necesita verificar varios valores, llamar a toList() hace que el flujo espere a que la fuente emita todos sus valores y, luego, los muestra en una lista. Esta opción solo funciona para flujos de datos finitos.

@Test
fun myRepositoryTest() = runTest {
    // Given a repository with a fake data source that emits ALL_MESSAGES
    val messages = repository.observeChatMessages().toList()

    // When all messages are emitted then they should be ALL_MESSAGES
    assertEquals(ALL_MESSAGES, messages)
}

En el caso de flujos de datos que requieren una recopilación de elementos más compleja o que no muestran una cantidad limitada de elementos, puedes usar la API de Flow para seleccionar y transformar elementos. Estos son algunos ejemplos:

// Take the second item
outputFlow.drop(1).first()

// Take the first 5 items
outputFlow.take(5).toList()

// Takes the first item verifying that the flow is closed after that
outputFlow.single()

// Finite data streams
// Verify that the flow emits exactly N elements (optional predicate)
outputFlow.count()
outputFlow.count(predicate)

Recopilación continua durante una prueba

La recopilación de un flujo con toList(), como en el ejemplo anterior, usa collect() de forma interna y se suspende hasta que la lista completa de resultados esté preparada para mostrarse.

Para intercalar acciones que hagan que el flujo emita valores y aserciones sobre los valores que se emitieron, puedes recopilar valores de un flujo de manera continua durante una prueba.

Por ejemplo, toma la siguiente clase Repository para probar y una implementación de fuente de datos falsa complementaria que tenga un método emit para producir valores de forma dinámica durante la prueba:

class Repository(private val dataSource: DataSource) {
    fun scores(): Flow<Int> {
        return dataSource.counts().map { it * 10 }
    }
}

class FakeDataSource : DataSource {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun counts(): Flow<Int> = flow
}

Cuando uses esta implementación falsa en una prueba, podrás crear una corrutina de recopilación que reciba los valores de Repository de manera continua. En este ejemplo, los recopilamos en una lista y, luego, realizamos aserciones sobre su contenido:

@Test
fun continuouslyCollect() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    val values = mutableListOf<Int>()
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        repository.scores().toList(values)
    }

    dataSource.emit(1)
    assertEquals(10, values[0]) // Assert on the list contents

    dataSource.emit(2)
    dataSource.emit(3)
    assertEquals(30, values[2])

    assertEquals(3, values.size) // Assert the number of items collected
}

Debido a que aquí no se completa el flujo que expone Repository, la llamada a toList que la recopila nunca se muestra. Iniciar la corrutina de recopilación en TestScope.backgroundScope garantiza que esta se cancele antes de que finalice la prueba. De lo contrario, runTest seguiría esperando su finalización, lo que haría que la prueba dejara de responder y, al final, fallara.

Observa cómo se usa UnconfinedTestDispatcher aquí para la corrutina de recopilación. Esto garantiza que la corrutina de recopilación se inicie con anticipación y esté lista para recibir valores después de que se muestre launch.

Cómo usar Turbine

La biblioteca de terceros Turbine ofrece una práctica API para crear una corrutina de recopilación, así como otras funciones útiles para probar flujos:

@Test
fun usingTurbine() = runTest {
    val dataSource = FakeDataSource()
    val repository = Repository(dataSource)

    repository.scores().test {
        // Make calls that will trigger value changes only within test{}
        dataSource.emit(1)
        assertEquals(10, awaitItem())

        dataSource.emit(2)
        awaitItem() // Ignore items if needed, can also use skip(n)

        dataSource.emit(3)
        assertEquals(30, awaitItem())
    }
}

Consulta la documentación de la biblioteca para obtener más información.

Cómo probar StateFlows

StateFlow es un contenedor de datos observables que se pueden recopilar para observar los valores que retienen con el tiempo como una transmisión continua. Ten en cuenta que esta transmisión continua de valores se combina, lo que significa que, si los valores se establecen en un StateFlow rápidamente, los recopiladores de ese StateFlow no tienen garantía de recibir todos los valores intermedios, solo el más reciente.

En las pruebas, si tienes en cuenta la combinación, puedes recopilar los valores de StateFlow de la misma manera que puedes recopilar cualquier otro flujo, incluso con Turbine. En algunas situaciones de prueba, te recomendamos que intentes recopilar y confirmar todos los valores intermedios.

Sin embargo, generalmente recomendamos tratar a StateFlow como un contenedor de datos y, en su lugar, confirmar en su propiedad value. De esta manera, las pruebas validan el estado actual del objeto en un momento dado y no dependen de si se produce o no la combinación.

Por ejemplo, toma este ViewModel que recopila valores de un Repository y los expone en la IU en un StateFlow:

class MyViewModel(private val myRepository: MyRepository) : ViewModel() {
    private val _score = MutableStateFlow(0)
    val score: StateFlow<Int> = _score.asStateFlow()

    fun initialize() {
        viewModelScope.launch {
            myRepository.scores().collect { score ->
                _score.value = score
            }
        }
    }
}

Una implementación falsa para este Repository podría verse de la siguiente manera:

class FakeRepository : MyRepository {
    private val flow = MutableSharedFlow<Int>()
    suspend fun emit(value: Int) = flow.emit(value)
    override fun scores(): Flow<Int> = flow
}

Cuando pruebas el ViewModel con esta implementación falsa, puedes emitir valores a partir de ella para activar las actualizaciones en el StateFlow de ViewModel y, luego, confirmar el value actualizado:

@Test
fun testHotFakeRepository() = runTest {
    val fakeRepository = FakeRepository()
    val viewModel = MyViewModel(fakeRepository)

    assertEquals(0, viewModel.score.value) // Assert on the initial value

    // Start collecting values from the Repository
    viewModel.initialize()

    // Then we can send in values one by one, which the ViewModel will collect
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value) // Assert on the latest value
}

Cómo trabajar con StateFlows creados por stateIn

En la sección anterior, ViewModel usa un MutableStateFlow para almacenar el valor más reciente emitido por un flujo de Repository. Este es un patrón común, que se suele implementar de forma más sencilla mediante el operador stateIn, que convierte un flujo frío en un StateFlow caliente:

class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
    val score: StateFlow<Int> = myRepository.scores()
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}

El operador stateIn tiene un parámetro SharingStarted que determina cuándo se activa y comienza a consumir el flujo subyacente. Las opciones como SharingStarted.Lazily y SharingStarted.WhileSubsribed se usan con frecuencia en ViewModels.

Incluso si confirmas en el value de StateFlow en tu prueba, deberás crear un recopilador. Puede ser un recopilador vacío:

@Test
fun testLazilySharingViewModel() = runTest {
    val fakeRepository = HotFakeRepository()
    val viewModel = MyViewModelWithStateIn(fakeRepository)

    // Create an empty collector for the StateFlow
    backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
        viewModel.score.collect()
    }

    assertEquals(0, viewModel.score.value) // Can assert initial value

    // Trigger-assert like before
    fakeRepository.emit(1)
    assertEquals(1, viewModel.score.value)

    fakeRepository.emit(2)
    fakeRepository.emit(3)
    assertEquals(3, viewModel.score.value)
}

Recursos adicionales