A forma como você testa unidades ou módulos que se comunicam com fluxos depende se o objeto em teste usa o fluxo como entrada ou saída.
- Se o objeto em teste observa um fluxo, gere fluxos em dependências fictícias que você pode controlar pelos testes.
- Se a unidade ou o módulo expõe um fluxo, leia e verifique um ou vários itens emitidos por um fluxo no teste.
Como criar um produtor fictício
Quando o objeto em teste é consumidor de um fluxo, uma maneira comum de testá-lo é substituir o produtor por uma implementação fictícia. Por exemplo, considerando uma classe que observa um repositório que usa dados de duas fontes na produção:
Para que o teste seja determinista, substitua o repositório e as dependências dele por um repositório fictício que sempre emite os mesmos dados fictícios:
Para emitir uma série predefinida de valores em um fluxo, use o builder flow
:
class MyFakeRepository : MyRepository {
fun observeCount() = flow {
emit(ITEM_1)
}
}
No teste, este repositório fictício é injetado, substituindo a implementação real:
@Test
fun myTest() {
// Given a class with fake dependencies:
val sut = MyUnitUnderTest(MyFakeRepository())
// Trigger and verify
...
}
Agora que você tem controle sobre as saídas do objeto em teste, elas podem ser usadas para verificar se tudo funciona corretamente.
Como declarar emissões de fluxo em um teste
Se o objeto em teste estiver expondo um fluxo, o teste precisará fazer declarações sobre os elementos do fluxo de dados.
Vamos supor que o repositório do exemplo anterior exponha um fluxo:
Em determinados testes, é necessário verificar apenas a primeira emissão ou um número finito de itens provenientes do fluxo.
Você pode consumir a primeira emissão para o fluxo chamando first()
. Essa
função aguarda até que o primeiro item seja recebido e depois envia o sinal de
cancelamento ao produtor.
@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)
}
Se o teste precisar conferir vários valores, chamar toList()
vai fazer com que o fluxo
aguarde a fonte emitir todos os valores e, em seguida, vai retornar esses valores como uma
lista. Isso funciona apenas para fluxos de dados 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)
}
Para fluxos de dados que exigem uma coleção mais complexa de itens ou que não retornam
um número finito de itens, você pode usar a API Flow
para selecionar e transformar os
itens. Confira alguns exemplos:
// 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)
Coleta contínua durante um teste
A coleta de um fluxo usando toList()
, como mostrado no exemplo anterior, usa
collect()
internamente e fica suspensa até que toda a lista de resultados esteja pronta para ser
retornada.
Para intercalar as ações que fazem o fluxo emitir valores e as declarações sobre os valores que foram emitidos, é possível coletar continuamente valores de um fluxo durante um teste.
Por exemplo, considere a seguinte classe Repository
a ser testada e uma
implementação de origem de dados fictícia com um método emit
para
produzir valores dinamicamente durante o teste:
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
}
Ao usar essa implementação fictícia em um teste, é possível criar uma corrotina de coleta que
vai receber continuamente os valores de Repository
. Neste exemplo, estamos
coletando esses valores em uma lista e fazendo declarações no conteúdo:
@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
}
Como o fluxo exposto pelo Repository
nunca é concluído, a chamada de
toList
que o coleta nunca é retornada. Como iniciar a corrotina de coleta em
TestScope.backgroundScope
garante que a corrotina seja cancelada antes do final do teste. Caso contrário,
o runTest
continuaria esperando a conclusão, o que faria o teste parar
de responder e falhar.
Observe como
UnconfinedTestDispatcher
é usado na corrotina de coleta. Isso garante que a corrotina
de coleta seja iniciada e esteja pronta para receber valores depois do retorno de
launch
.
Como usar a biblioteca Turbine
A ferramenta de terceiros Turbine oferece uma API conveniente para criar uma corrotina de coleta, bem como como outros recursos práticos para testar fluxos:
@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())
}
}
Consulte mais detalhes na documentação da biblioteca.
Como testar StateFlows
StateFlow
é um armazenador
de dados observáveis que pode ser coletado para observar os valores retidos ao longo do tempo como
um stream. Esse stream é uma combinação, ou seja, se
os valores forem definidos em um StateFlow
rapidamente, os coletores desse StateFlow
não vão ter
garantia de receber todos os valores intermediários, apenas o mais recente.
Em testes, tendo essa característica em mente, você pode coletar os valores de um StateFlow
da mesma forma que qualquer outro fluxo, inclusive com a biblioteca Turbine. Tentar coletar
e declarar em todos os valores intermediários pode ser desejável em alguns cenários de teste.
No entanto, geralmente recomendamos tratar StateFlow
como um armazenador de dados e
fazer as declarações na propriedade value
. Dessa forma, os testes validam o estado
atual do objeto em um determinado momento e não dependem
da combinação.
Um exemplo é este ViewModel que coleta valores de um Repository
e
os expõe à interface em um 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
}
}
}
}
Uma implementação fictícia desse Repository
pode ter esta aparência:
class FakeRepository : MyRepository {
private val flow = MutableSharedFlow<Int>()
suspend fun emit(value: Int) = flow.emit(value)
override fun scores(): Flow<Int> = flow
}
Ao testar o ViewModel com essa implementação, você pode emitir valores dela para
acionar atualizações no StateFlow
do ViewModel e depois fazer a declaração no
value
atualizado:
@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
}
Como trabalhar com StateFlows criados por stateIn
Na seção anterior, o ViewModel usa um MutableStateFlow
para armazenar o
valor mais recente emitido por um fluxo do Repository
. Esse é um padrão comum,
geralmente implementados de forma mais simples, usando o
stateIn
que converte um fluxo frio em um StateFlow
quente:
class MyViewModelWithStateIn(myRepository: MyRepository) : ViewModel() {
val score: StateFlow<Int> = myRepository.scores()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000L), 0)
}
O operador stateIn
tem um parâmetro SharingStarted
, que determina quando
ele ficará ativo e começará a consumir o fluxo. Opções como
SharingStarted.Lazily
e SharingStarted.WhileSubsribed
são usadas com frequência
em ViewModels.
Mesmo que você esteja declarando no value
do StateFlow
no teste, é
necessário criar um coletor. Ele pode ser um coletor vazio:
@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)
}
Outros recursos
- Como testar corrotinas do Kotlin no Android
- Fluxos Kotlin no Android
StateFlow
eSharedFlow
- Outros recursos para corrotinas e fluxos do Kotlin