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