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 é um 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, é possível verificar se ele funciona corretamente conferindo as saídas dele.

Como declarar emissões de fluxo em um teste

Se o objeto em teste estiver expondo um fluxo, o teste vai 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, em seguida, 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 com que o fluxo emita 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()) {
        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 de valores é 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, se você lembrar dessa característica, vai poder coletar os valores de um StateFlow durante a coleta de 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 do acontecimento 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, em seguida, 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 vai ficar ativo e começa a consumir o fluxo subjacente. 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()) { 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