Flow virou uma das peças centrais do Kotlin moderno. Ele aparece em ViewModels Android, repositórios com Room, integrações com APIs, pipelines backend, streamings em Ktor e até em fluxos de IA que precisam emitir estado parcial. O problema é que muita equipe aprende a escrever Flow, mas continua testando código assíncrono com delay, Thread.sleep, mocks frágeis ou asserts feitos cedo demais. O resultado são testes lentos, instáveis e pouco confiáveis.
É aí que entra o Turbine, uma biblioteca simples e muito prática para testar emissões de Flow, StateFlow e SharedFlow. Em vez de coletar manualmente uma lista, controlar coroutine na mão e torcer para o tempo bater, você escreve o teste como uma conversa com o fluxo: espera o próximo item, valida o valor, confirma que não há eventos extras ou cancela a coleta de forma explícita.
Este guia mostra como usar Turbine com kotlinx-coroutines-test, runTest, ViewModel e eventos de UI. Se você ainda está revisando a base, leia também nosso conteúdo sobre Kotlin Flow, coroutines em Kotlin e o guia de testes Kotlin. A ideia aqui é sair do conceito e chegar em testes que um time consegue manter em produção.
Por que testar Flow exige cuidado?
Um Flow não é apenas uma função que retorna um valor. Ele pode emitir vários valores, suspender, trocar de dispatcher, combinar fontes, lidar com erro, manter estado quente ou depender do ciclo de vida de quem coleta. Isso muda a forma de testar.
Alguns problemas comuns aparecem rápido:
- o teste termina antes do
Flowemitir; - um
delayreal deixa a suíte lenta; - um
StateFlowemite o estado inicial e o teste ignora isso; - um
SharedFlowperde evento porque ninguém estava coletando; - uma coroutine fica ativa depois do teste;
- a ordem das emissões muda por causa de concorrência mal controlada.
Turbine ajuda porque coloca essas expectativas no código do teste. Você deixa claro quando espera um item, quando não espera mais nada e quando a coleta deve acabar. Isso reduz testes intermitentes, principalmente em projetos Android com ViewModel, StateFlow e eventos de tela.
Dependências recomendadas
Em um projeto Kotlin com Gradle, normalmente você combina Turbine com kotlinx-coroutines-test. As versões mudam, então confira a versão atual antes de publicar em um projeto real.
dependencies {
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2")
testImplementation("app.cash.turbine:turbine:1.2.0")
}
Para testes Android locais de ViewModel, você também costuma usar JUnit, MockK ou fakes manuais. O importante é que a regra de negócio testada rode na JVM sempre que possível. Testes instrumentados devem ficar para o que depende de framework Android, renderização ou integração real de dispositivo. Essa separação conversa bem com o nosso guia de testes Android com Compose e Maestro.
Primeiro teste com Flow simples
Vamos começar com um caso pequeno. Imagine um repositório que emite o progresso de sincronização de dados.
class SincronizacaoRepository {
fun progresso(): Flow<Int> = flow {
emit(0)
emit(50)
emit(100)
}
}
Com Turbine, o teste fica direto:
import app.cash.turbine.test
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
class SincronizacaoRepositoryTest {
@Test
fun `deve emitir progresso em ordem`() = runTest {
val repository = SincronizacaoRepository()
repository.progresso().test {
assertEquals(0, awaitItem())
assertEquals(50, awaitItem())
assertEquals(100, awaitItem())
awaitComplete()
}
}
}
O ganho é legibilidade. O teste diz exatamente o que o fluxo deve emitir e em qual ordem. awaitComplete() confirma que o Flow terminou. Se uma emissão faltar, sobrar ou vier fora de ordem, o teste falha de forma compreensível.
Testando StateFlow em ViewModel
StateFlow é um pouco diferente porque sempre possui um valor atual. Quando você começa a coletar, o primeiro item normalmente é o estado inicial. Isso é correto, mas costuma pegar iniciantes de surpresa.
data class BuscaUiState(
val carregando: Boolean = false,
val resultados: List<String> = emptyList(),
val erro: String? = null,
)
class BuscaViewModel(
private val service: BuscaService,
) : ViewModel() {
private val _uiState = MutableStateFlow(BuscaUiState())
val uiState: StateFlow<BuscaUiState> = _uiState.asStateFlow()
fun buscar(termo: String) {
viewModelScope.launch {
_uiState.value = BuscaUiState(carregando = true)
val resultados = service.buscar(termo)
_uiState.value = BuscaUiState(resultados = resultados)
}
}
}
Um teste saudável valida o estado inicial, a transição de carregamento e o estado final:
@Test
fun `deve atualizar estado ao buscar resultados`() = runTest {
val service = FakeBuscaService(resultados = listOf("Kotlin", "Compose"))
val viewModel = BuscaViewModel(service)
viewModel.uiState.test {
assertEquals(BuscaUiState(), awaitItem())
viewModel.buscar("kot")
assertEquals(BuscaUiState(carregando = true), awaitItem())
assertEquals(
BuscaUiState(resultados = listOf("Kotlin", "Compose")),
awaitItem(),
)
cancelAndIgnoreRemainingEvents()
}
}
cancelAndIgnoreRemainingEvents() é útil quando o fluxo é quente e não termina sozinho, como um StateFlow exposto por ViewModel. Sem isso, o teste pode ficar esperando conclusão que nunca virá. O ponto é assumir explicitamente que aquele fluxo continua vivo, mas que as emissões relevantes já foram validadas.
Testando erro sem depender de exceção solta
Em UI Android, muitas equipes preferem representar erro dentro do estado em vez de deixar exceção escapar para a camada visual. Isso torna o comportamento mais previsível.
@Test
fun `deve mostrar erro quando busca falhar`() = runTest {
val service = FakeBuscaService(erro = IllegalStateException("API fora do ar"))
val viewModel = BuscaViewModel(service)
viewModel.uiState.test {
awaitItem() // estado inicial
viewModel.buscar("kotlin")
assertEquals(true, awaitItem().carregando)
val estadoErro = awaitItem()
assertEquals(false, estadoErro.carregando)
assertEquals("API fora do ar", estadoErro.erro)
cancelAndIgnoreRemainingEvents()
}
}
Essa abordagem força o ViewModel a transformar falhas em estado visível. Para produto, isso é melhor do que ter uma tela silenciosa ou uma coroutine cancelada sem feedback. Para o teste, também evita depender de stack trace como contrato de comportamento.
SharedFlow para eventos pontuais
SharedFlow costuma aparecer em eventos que não são exatamente estado: navegação, snackbar, analytics, pedido de foco ou confirmação de ação. O cuidado é que eventos podem ser perdidos se a coleta começar tarde demais.
sealed interface PerfilEvento {
data object NavegarParaHome : PerfilEvento
data class MostrarMensagem(val texto: String) : PerfilEvento
}
class PerfilViewModel : ViewModel() {
private val _eventos = MutableSharedFlow<PerfilEvento>()
val eventos: SharedFlow<PerfilEvento> = _eventos.asSharedFlow()
fun salvar() {
viewModelScope.launch {
_eventos.emit(PerfilEvento.MostrarMensagem("Perfil salvo"))
_eventos.emit(PerfilEvento.NavegarParaHome)
}
}
}
No teste, comece a coletar antes de disparar a ação:
@Test
fun `deve emitir eventos ao salvar perfil`() = runTest {
val viewModel = PerfilViewModel()
viewModel.eventos.test {
viewModel.salvar()
assertEquals(
PerfilEvento.MostrarMensagem("Perfil salvo"),
awaitItem(),
)
assertEquals(PerfilEvento.NavegarParaHome, awaitItem())
expectNoEvents()
cancelAndIgnoreRemainingEvents()
}
}
expectNoEvents() documenta que a ação não deve emitir mais nada naquele momento. Esse detalhe parece pequeno, mas captura regressões comuns, como duas snackbars duplicadas ou navegação disparada duas vezes depois de uma refatoração.
Evite delay real no teste
O erro mais comum em testes de coroutines é colocar delay(1000) para esperar emissão. Isso deixa a suíte lenta e ainda não garante determinismo. Com runTest, você pode controlar tempo virtual quando o código usa delays bem estruturados.
fun ticker(): Flow<Int> = flow {
emit(1)
delay(1_000)
emit(2)
}
@Test
fun `deve emitir depois do tempo virtual`() = runTest {
ticker().test {
assertEquals(1, awaitItem())
testScheduler.advanceTimeBy(1_000)
assertEquals(2, awaitItem())
awaitComplete()
}
}
Tempo virtual torna o teste rápido e previsível. Se o seu código depende de Dispatchers.IO, Dispatchers.Main ou escopos externos, injete dispatchers no componente testado. Essa prática melhora testabilidade e também deixa a arquitetura mais clara.
Boas práticas para Turbine em projetos reais
Algumas regras ajudam a manter a suíte saudável:
- valide o estado inicial de
StateFlowem vez de ignorá-lo sem pensar; - use fakes simples para regra de negócio antes de recorrer a mocks complexos;
- evite
Thread.sleepedelayreal em testes; - cancele fluxos quentes com
cancelAndIgnoreRemainingEvents(); - use
expectNoEvents()quando ausência de emissão é parte do contrato; - prefira testar ViewModel e use cases na JVM;
- mantenha testes instrumentados para UI, permissões, navegação real e integração de framework.
Também vale separar teste de transformação e teste de UI. Se uma função transforma Flow<Pedido> em Flow<ResumoPedido>, teste essa transformação isoladamente. Se o ViewModel apenas conecta a transformação ao estado da tela, teste a transição de UiState. Não tente cobrir tudo em um único teste gigante.
Turbine no backend Kotlin
Embora Turbine apareça muito em Android, ele também é útil no backend. Em Ktor, Spring WebFlux ou pipelines internos, Flow pode representar streaming de eventos, processamento de mensagens, tarefas em lote ou respostas parciais. Testar emissões com Turbine ajuda a validar ordem, erro e conclusão sem subir infraestrutura completa.
class RelatorioService {
fun gerarEventos(): Flow<String> = flow {
emit("iniciado")
emit("processando")
emit("concluido")
}
}
@Test
fun `deve emitir eventos do relatorio`() = runTest {
val service = RelatorioService()
service.gerarEventos().test {
assertEquals("iniciado", awaitItem())
assertEquals("processando", awaitItem())
assertEquals("concluido", awaitItem())
awaitComplete()
}
}
Em times poliglotas, essa disciplina se compara bem a padrões de outras linguagens. O ecossistema Python Brasil costuma usar pytest para fixtures e testes assíncronos, enquanto Go prioriza testes explícitos com tabelas e concorrência controlada. Em Kotlin, Turbine ocupa um espaço parecido para streams: deixar o comportamento assíncrono verificável sem esconder o contrato.
Checklist final
Antes de considerar seus testes de Flow maduros, revise:
- o teste usa
runTest; - emissões são validadas em ordem;
StateFlowtem estado inicial coberto;- fluxos quentes são cancelados explicitamente;
- não existe
Thread.sleep; - dispatchers são injetáveis quando necessário;
- erros são representados de forma testável;
- eventos pontuais não são perdidos por coleta tardia.
Turbine não substitui uma boa arquitetura, mas expõe rapidamente onde ela está frágil. Se um Flow é difícil de testar, talvez o problema esteja no excesso de responsabilidade do ViewModel, no dispatcher fixo, no evento modelado como estado ou no estado modelado como evento. Use esses testes como feedback de design, não apenas como uma etapa burocrática do CI.
Para continuar evoluindo, combine este guia com MVVM em Kotlin, Kotlin Flow, testes com JUnit 5 e MockK e testes Android com Compose e Maestro. A diferença entre um app Kotlin que “funciona na máquina” e um app confiável em produção quase sempre passa por testes assíncronos bem escritos.