Setup de testing

// build.gradle (app)
dependencies {
    testImplementation("junit:junit:4.13.2")
    testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
    testImplementation("io.mockk:mockk:1.13.10")            // mocking
    testImplementation("app.cash.turbine:turbine:1.1.0")    // testing de flows
    testImplementation("com.google.truth:truth:1.4.2")       // assertions legibles
    testImplementation("androidx.arch.core:core-testing:2.2.0")
}

runTest — el builder para tests con coroutines

runTest crea un scope de testing con un reloj virtual controlado. Las llamadas a delay() avanzan instantáneamente — no tenés que esperar tiempos reales en tus tests.

class ProductoUseCaseTest {

    @Test
    fun `getProductosDisponibles retorna solo productos con stock`() = runTest {
        // Arrange
        val fakeRepo = FakeProductoRepository(
            productos = listOf(
                Producto(1, "Con stock", 100.0, stock = 5, Categoria.ELECTRONICA),
                Producto(2, "Sin stock", 50.0, stock = 0, Categoria.ELECTRONICA)
            )
        )
        val useCase = GetProductosDisponiblesUseCase(fakeRepo)

        // Act
        val resultado = useCase().first()  // first() colecta el primer valor del Flow

        // Assert
        assertThat(resultado).hasSize(1)
        assertThat(resultado.first().nombre).isEqualTo("Con stock")
    }
}

Fakes vs Mocks

Un fake es una implementación real pero simplificada de una interfaz, pensada para tests. Un mock es un objeto generado que registra llamadas y puede configurarse para retornar valores.

// FAKE — preferido para Repositories y dependencias con estado
class FakeProductoRepository(
    private val productos: List<Producto> = emptyList()
) : ProductoRepository {

    private val _productos = MutableStateFlow(productos)
    val productosGuardados = mutableListOf<Producto>()

    override fun getProductos(): Flow<List<Producto>> = _productos

    override suspend fun guardar(producto: Producto) {
        productosGuardados.add(producto)
        _productos.value = _productos.value + producto
    }

    override suspend fun getProducto(id: Int) = _productos.value.find { it.id == id }

    // Método de test para simular errores
    fun lanzarError() {
        throw IOException("Error simulado")
    }
}

// MOCK — útil para verificar interacciones o cuando el fake sería complejo
@Test
fun `guardar llama al repositorio exactamente una vez`() = runTest {
    val mockRepo = mockk<ProductoRepository>()
    coEvery { mockRepo.guardar(any()) } just Runs

    val viewModel = ProductosViewModel(mockRepo)
    viewModel.guardar(unProducto)

    coVerify(exactly = 1) { mockRepo.guardar(unProducto) }
}

Preferí fakes sobre mocksLos fakes son más fáciles de mantener a largo plazo. Los mocks son frágiles: si cambiás la implementación (pero no el contrato), los tests con mocks fallan aunque el comportamiento sea correcto. Los fakes testean comportamiento real.

TestDispatcher — controlar el tiempo

// StandardTestDispatcher — las coroutines no corren solas, necesitás avanzarlas
// UnconfinedTestDispatcher — las coroutines corren inmediatamente (más simple)
class ProductosViewModelTest {

    // Regla que reemplaza Dispatchers.Main con un TestDispatcher
    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    @Test
    fun `estado inicial es Loading`() = runTest {
        val viewModel = ProductosViewModel(FakeProductoRepository())
        assertThat(viewModel.uiState.value.isLoading).isTrue()
    }
}

// La regla necesaria para reemplazar Main dispatcher:
class MainDispatcherRule(
    val dispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
    override fun starting(description: Description) {
        Dispatchers.setMain(dispatcher)
    }
    override fun finished(description: Description) {
        Dispatchers.resetMain()
    }
}

Testeando el ViewModel — ejemplo completo

class ProductosViewModelTest {

    @get:Rule val mainDispatcherRule = MainDispatcherRule()

    private lateinit var fakeRepo: FakeProductoRepository
    private lateinit var viewModel: ProductosViewModel

    @Before
    fun setup() {
        fakeRepo = FakeProductoRepository(listaDeProductos)
        viewModel = ProductosViewModel(
            GetProductosDisponiblesUseCase(fakeRepo)
        )
    }

    @Test
    fun `uiState emite Success con productos cuando carga correctamente`() = runTest {
        val estado = viewModel.uiState.value
        assertThat(estado.isLoading).isFalse()
        assertThat(estado.productos).isNotEmpty()
        assertThat(estado.error).isNull()
    }

    @Test
    fun `buscar filtra la lista correctamente`() = runTest {
        viewModel.onAction(ProductosAction.Buscar("Tablet"))
        val estado = viewModel.uiState.value
        assertThat(estado.productos.all { it.nombre.contains("Tablet") }).isTrue()
    }

    @Test
    fun `eliminar remueve el producto de la lista`() = runTest {
        val idAEliminar = listaDeProductos.first().id
        viewModel.onAction(ProductosAction.EliminarProducto(idAEliminar))
        val estado = viewModel.uiState.value
        assertThat(estado.productos.none { it.id == idAEliminar }).isTrue()
    }
}

Turbine — testeando flows secuencialmente

Turbine es la librería estándar para testear flows. Permite asertar sobre cada item emitido en orden:

@Test
fun `flow emite Loading luego Success`() = runTest {
    val viewModel = ProductosViewModel(FakeProductoRepository(listaDeProductos))

    viewModel.uiState.test {
        // Primer estado emitido
        val primero = awaitItem()
        assertThat(primero.isLoading).isTrue()

        // Segundo estado emitido
        val segundo = awaitItem()
        assertThat(segundo.isLoading).isFalse()
        assertThat(segundo.productos).isNotEmpty()

        cancelAndIgnoreRemainingEvents()
    }
}

// Para eventos únicos (Channel):
@Test
fun `guardar emite evento NavAtras`() = runTest {
    viewModel.eventos.test {
        viewModel.guardar(unProducto)
        assertThat(awaitItem()).isInstanceOf(UiEvento.NavAtras::class.java)
        cancelAndIgnoreRemainingEvents()
    }
}

Estructura AAA y nombres descriptivos

// Arrange — Act — Assert: la estructura que hace los tests legibles
@Test
fun `cuando el repositorio falla, uiState contiene mensaje de error`() = runTest {
    // Arrange: preparar el escenario
    val repo = FakeProductoRepository()
    repo.configurarParaFallar(IOException("Sin internet"))
    val viewModel = ProductosViewModel(GetProductosDisponiblesUseCase(repo))

    // Act: ejecutar la acción bajo test
    viewModel.onAction(ProductosAction.Recargar)

    // Assert: verificar el resultado esperado
    val estado = viewModel.uiState.value
    assertThat(estado.isLoading).isFalse()
    assertThat(estado.error).isNotNull()
    assertThat(estado.error).contains("Sin internet")
}

Nombrá los tests como especificacionesEl nombre del test es documentación. Usá el patrón backtick de Kotlin para nombres en lenguaje natural: `cuando X ocurre, entonces Y`. Cuando el test falla, el nombre te dice exactamente qué comportamiento se rompió.