Como testar fluxos do Kotlin no Android

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, é possível gerar fluxos em dependências fictícias que você pode controlar pelos testes.
  • Se a unidade ou o módulo expõe um fluxo, é possível ler e verificar 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:

o objeto em teste e a camada de dados
Figura 1. O objeto em teste e a camada de dados.

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:

as dependências são substituídas por uma implementação fictícia
Figura 2. As dependências são substituídas por uma implementação fictícia.

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, esse 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:

um repositório com dependências fictícias que expõem um fluxo
Figura 3. Um repositório (o objeto em teste) com dependências fictícias que expõem 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 não retornam um número finito de itens, você pode usar a API Flow para selecionar e transformar os itens. Veja 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 ações que fazem o fluxo emitir valores e declarações nos 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>()
    val collectJob = 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

    collectJob.cancel()
}

Como o fluxo exposto pelo Repository nunca é concluído, a chamada de toList que o coleta nunca é retornada. Portanto, a corrotina de coleta precisa ser cancelada explicitamente antes do final do teste. Caso contrário, o runTest continuaria esperando a conclusão, fazendo com que o teste parasse de responder e falhasse.

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 biblioteca de terceiros Turbine oferece uma API conveniente para criar uma corrotina de coleta, bem 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 a documentação da biblioteca para ver mais detalhes.

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 detentor 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.

Por exemplo, veja este ViewModel, que coleta valores de um Repository e os expõe à IU 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 implementado de maneira mais simples, usando o operador 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
    val collectJob = 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)

    // Cancel the collecting job at the end of the test
    collectJob.cancel()
}

Outros recursos