Capítulo 16: Testes Unitários, Testes de UI no Compose e Testes de Coroutines

Especialização em Mobile Nativo com Android • Kotlin 2.0 & Jetpack Compose


🗺️ Mapa Conceitual da Pirâmide de Testes no Android Moderno

flowchart TD
    subgraph PYRAMID["Pirâmide de Testes de Software Mobile"]
        E2E["1. Testes de Integração / E2E (Dispositivo Real / Emulador com Espresso)"]
        UI["2. Testes de Componentes Compose (ComposeTestRule & Semantics Tree)"]
        UNIT["3. Testes Unitários de Domínio e ViewModels (JVM Host Puro / JUnit 5 + MockK + Turbine)"]
        E2E --> UI --> UNIT
    end

    subgraph UNIT_TOOLS["Ferramentas de Teste Unitário (Rápidos: < 200ms)"]
        RUN_TEST["kotlinx.coroutines.test.runTest"]
        TURBINE["Turbine: app.cash.turbine.test { awaitItem() }"]
        MOCKK["MockK: coEvery / coVerify"]
        RUN_TEST --> TURBINE
        MOCKK --> RUN_TEST
    end

    subgraph COMPOSE_TOOLS["Ferramentas de Teste de UI no Compose"]
        RULE["createComposeRule()"]
        SEMANTICS["Busca Semântica: onNodeWithTag / onNodeWithText"]
        ACTIONS["Ações & Asserções: performClick() / assertIsDisplayed()"]
        RULE --> SEMANTICS --> ACTIONS
    end

    UNIT -.-> UNIT_TOOLS
    UI -.-> COMPOSE_TOOLS

    style UNIT fill:#e8f5e9,stroke:#4caf50,stroke-width:2px
    style UI fill:#fff3e0,stroke:#ff9800,stroke-width:2px
    style E2E fill:#fce4ec,stroke:#e91e63,stroke-width:2px
    style TURBINE fill:#ede7f6,stroke:#7e57c2,stroke-width:2px
    style SEMANTICS fill:#e1f5fe,stroke:#03a9f4,stroke-width:2px

🏛️ 1. A Estratégia de Qualidade e Pirâmide de Testes no Android

Garantir a estabilidade de um aplicativo com dezenas de telas e fluxos assíncronos requer uma estratégia de testes automatizados dividida em camadas complementares:

  1. Testes Unitários Locais na JVM (70% do Esforço): Executam diretamente no computador de desenvolvimento ou servidor de CI/CD sem necessidade de emuladores Android. Testam entidades de domínio, regras de negócio de ViewModels e conversores de dados. Com kotlinx-coroutines-test e a biblioteca Turbine, os tempos virtuais de coroutines são adiantados instantaneamente sem esperas arbitrárias de Thread.sleep().
  2. Testes de UI Declarativa com Compose Test Rule (20% do Esforço): O Jetpack Compose não interage com a tela inspecionando pixels crus; ele gera uma Árvore Semântica (Semantics Tree) paralela à árvore de nós visuais. A biblioteca de testes oficial utiliza essa semântica para localizar nós por texto legível (onNodeWithText) ou identificadores de automação (testTag), simulando gestos de clique e validando se o estado visual foi atualizado.
  3. Determinismo Temporal com runTest: O executor runTest substitui os despachadores reais de Coroutine por um TestCoroutineScheduler. Qualquer chamada com delay(10_000) é executada virtualmente em 0 milissegundos no teste, garantindo execuções de testes ultrarrápidas e determinísticas.

📊 2. Comparativo: Testes Unitários de JVM vs Testes Instrumentados no Dispositivo

Característica Testes Unitários na JVM (test/) Testes Instrumentados Android (androidTest/)
Ambiente de Execução JVM da máquina hospedeira local (Host PC / CI) Emulador Android real ou dispositivo físico conectado
Tempo de Inicialização Instantâneo (~1 a 3 segundos para centenas de testes) Lento (~30 a 60 segundos para subir o APK de teste)
Alvos de Teste ViewModels, UseCases, Repositories, Redutores MVI Banco Room real, Activities, Serviços, Gestos complexos de UI
Dependência do SDK Android Nenhuma (ou simulado via mocks leves) Acesso total ao android.content.Context real do SO
Custo de Infraestrutura CI Baixo (roda em runners Linux leves e baratos) Elevado (exige runners com virtualização de hardware KVM)

💻 3. Laboratório Prático: Testando ViewModel com Turbine e MockK

O código abaixo demonstra um teste unitário profissional validando as emissões reativas de um ViewModel sem instanciar emuladores:

package br.com.portal.mobile.android.cap16

import app.cash.turbine.test
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test

@OptIn(ExperimentalCoroutinesApi::class)
class NoticiasViewModelTest {

    private val testDispatcher = StandardTestDispatcher()
    private val mockRepository = mockk<NoticiaRepository>()
    private lateinit var viewModel: NoticiasViewModel

    @Before
    fun configurar() {
        // Redireciona o Dispatchers.Main do Android para o despachador virtual de testes
        Dispatchers.setMain(testDispatcher)
    }

    @After
    fun finalizar() {
        Dispatchers.resetMain()
    }

    @Test
    fun `quando carregarNoticias tiver sucesso, deve emitir estado com lista preenchida`() = runTest {
        // 1. Arrange: Configuração dos mocks
        val noticiasMock = listOf("Lançamento Android 15", "Kotlin 2.0 Estável")
        coEvery { mockRepository.obterNoticias() } returns flowOf(noticiasMock)

        viewModel = NoticiasViewModel(mockRepository)

        // 2. Act & Assert com a biblioteca Turbine
        viewModel.uiState.test {
            // Primeiro item emitido é o estado inicial de Loading
            val estadoInicial = awaitItem()
            assertEquals(true, estadoInicial.carregando)

            // Avança o tempo virtual do despachador
            testDispatcher.scheduler.advanceUntilIdle()

            // Segundo item emitido após o sucesso da coroutine
            val estadoSucesso = awaitItem()
            assertFalse(estadoSucesso.carregando)
            assertEquals(2, estadoSucesso.itens.size)
            assertEquals("Lançamento Android 15", estadoSucesso.itens[0])

            cancelAndIgnoreRemainingEvents()
        }
    }
}

⚠️ 4. Armadilhas Comuns na Criação de Testes Mobile

  1. Uso de Thread.sleep() em Testes de UI: Adicionar pausas estáticas Thread.sleep(2000) para esperar chamadas de rede terminarem torna a suíte de testes lenta, intermitente e propensa a falsos negativos (Flaky Tests). Prefira sempre waitUntilDoesNotExist ou APIs de sincronização do Compose Test Rule.
  2. Esquecer de Resetar o Dispatchers.Main: Ao substituir o despachador principal em testes unitários com Dispatchers.setMain(), sempre invoque Dispatchers.resetMain() no método com @After. Caso contrário, os testes seguintes podem falhar com vazamento de estado de dispatcher.
  3. Acoplamento a Strings Visuais nos Testes de UI: Buscar nós estritamente por texto visível (onNodeWithText("Comprar")) pode quebrar todos os testes ao traduzir o app para outros idiomas (i18n). Utilize Modifier.testTag("btn_comprar") para seletores estáveis de automação.