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:
Para que la prueba sea determinante, puedes reemplazar el repositorio y sus dependencias por un repositorio falso que siempre emita los mismos datos falsos:
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:
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
del 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 una manera más simple 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.WhileSubscribed
se usan con frecuencia en los modelos de vista.
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
- Cómo probar corrutinas de Kotlin en Android
- Flujos de Kotlin en Android
StateFlow
ySharedFlow
- Recursos adicionales para las corrutinas y el flujo de Kotlin