El módulo :core:testing — el más subestimado
En un proyecto multimodulo, el módulo :core:testing es tan importante como :core:domain. Sin él, cada módulo que quiere testear algo duplica sus propios Fakes, y los tests de distintos módulos terminan testeando implementaciones distintas de las mismas interfaces.
# :core:testing — solo existe en el classpath de test, nunca en producción
core/testing/
└── src/main/kotlin/ar/pensa/app/core/testing/
├── fakes/
│ ├── FakeProductoRepository.kt
│ ├── FakeCheckoutRepository.kt
│ ├── FakeCarritoRepository.kt
│ └── FakeNetworkMonitor.kt
├── rules/
│ └── MainDispatcherRule.kt # compartida entre todos los módulos
└── data/
└── TestData.kt # objetos de test reutilizables
// :core:testing/build.gradle.kts
plugins {
alias(libs.plugins.miapp.android.library)
}
dependencies {
// Las dependencias de test son implementation (no testImplementation)
// porque este módulo ES el módulo de testing — su código es siempre de test
implementation(libs.kotlinx.coroutines.test)
implementation(libs.turbine)
implementation(libs.junit4)
implementation(libs.truth)
implementation(libs.mockk)
// Las interfaces que los Fakes implementan:
implementation(project(":core:domain"))
}
// Cada módulo que necesita los Fakes los agrega como testImplementation:
// :feature:checkout/build.gradle.kts
dependencies {
testImplementation(project(":core:testing"))
androidTestImplementation(project(":core:testing"))
}
Fakes compartidos — implementados una sola vez
// :core:testing/fakes/FakeProductoRepository.kt
// Este Fake lo usan :feature:productos, :feature:checkout, y cualquier test
// que necesite productos — sin duplicar código
class FakeProductoRepository : ProductoRepository {
private val _productos = MutableStateFlow<List<Producto>>(emptyList())
private var errorSimulado: Exception? = null
override fun getProductosFlow(): Flow<List<Producto>> = _productos
override suspend fun getProductos(): List<Producto> {
errorSimulado?.let { throw it }
return _productos.value
}
override suspend fun getProducto(id: Int): Producto? =
_productos.value.find { it.id == id }
override suspend fun buscar(query: String): List<Producto> =
_productos.value.filter { it.nombre.contains(query, ignoreCase = true) }
// Helpers para configurar el estado desde cualquier test:
fun setProductos(lista: List<Producto>) { _productos.value = lista }
fun setError(e: Exception) { errorSimulado = e }
fun reset() { _productos.value = emptyList(); errorSimulado = null }
}
// :core:testing/data/TestData.kt — objetos de prueba reutilizables
object TestData {
val producto1 = Producto(id = 1, nombre = "Mouse", precio = 2000.0)
val producto2 = Producto(id = 2, nombre = "Teclado", precio = 5000.0)
val producto3 = Producto(id = 3, nombre = "Monitor", precio = 80000.0)
val listaProductos = listOf(producto1, producto2, producto3)
val carrito = Carrito(items = listOf(
ItemCarrito(producto1, cantidad = 2),
ItemCarrito(producto2, cantidad = 1)
))
}
Testear un feature module — el patrón
// :feature:checkout/src/test/
// Los tests del módulo checkout solo necesitan :core:testing
// No importan nada de :feature:productos ni de :app
class CheckoutViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule() // de :core:testing
// Fakes de :core:testing — no hay que crear nada aquí
private val fakeCarritoRepo = FakeCarritoRepository()
private val fakePagosRepo = FakeCheckoutRepository()
private val viewModel = CheckoutViewModel(
carritoRepository = fakeCarritoRepo,
procesarPago = ProcesarPagoUseCase(fakePagosRepo)
)
@Before
fun setUp() {
// Usar los datos de TestData para tener contexto real
fakeCarritoRepo.setCarrito(TestData.carrito)
}
@Test
fun `iniciar checkout carga el carrito correctamente`() = runTest {
viewModel.iniciar()
assertThat(viewModel.uiState.value.items).hasSize(2)
}
@Test
fun `procesar pago exitoso navega a confirmacion`() = runTest {
fakePagosRepo.setPagoExitoso(numeroPedido = "ORD-001")
viewModel.procesarPago(DatosPago.tarjeta())
viewModel.effects.test {
val effect = awaitItem()
assertThat(effect).isInstanceOf(CheckoutEffect.NavegaAConfirmacion::class.java)
cancelAndIgnoreRemainingEvents()
}
}
}
Sin dependencias de producción entre módulos en tests
El error más frecuente: un test en :feature:checkout importa ProductoRepositoryImpl de :feature:productos para tener datos reales. Eso crea una dependencia de producción entre dos features en el grafo de Gradle — exactamente lo que los módulos intentan evitar.
// ❌ MAL — crea dependencia de producción entre features en los tests
// :feature:checkout/build.gradle.kts
dependencies {
testImplementation(project(":feature:productos")) // ← esto es un problema
}
// En el test:
class CheckoutViewModelTest {
// Usando la implementación real de productos en un test de checkout
private val productosRepo = ProductoRepositoryImpl(mockDao, mockApi)
}
// ✓ BIEN — usa el Fake de :core:testing
class CheckoutViewModelTest {
private val productosRepo = FakeProductoRepository().apply {
setProductos(TestData.listaProductos)
}
}
:core:testing no tiene dependencias de producción de featuresEl módulo :core:testing depende de :core:domain (las interfaces) pero nunca de :feature:*. Los Fakes implementan las interfaces de dominio — no dependen de las implementaciones reales.
Correr tests por módulo
# El superpoder del multimodulo: testear solo lo que cambió
# Correr solo los tests de un módulo específico:
./gradlew :feature:checkout:test
./gradlew :core:domain:test
# Correr tests de todos los módulos en paralelo:
./gradlew test
# Ver qué módulos fueron afectados por un cambio (requires Gradle Enterprise):
./gradlew :feature:checkout:dependencies
# Verificar que :feature:checkout no tiene dependencias inesperadas:
./gradlew :feature:checkout:dependencies --configuration testRuntimeClasspath | grep "feature:"
# Si aparece otra feature → hay una dependencia que no debería existir
Estrategia de CI en proyectos multimodulo
# La ventaja principal de multimodulo en CI:
# solo correr los tests de los módulos que cambiaron en el PR
# Con GitHub Actions + Gradle Build Scans:
# 1. Detectar qué módulos cambiaron en el PR
# 2. Calcular qué módulos dependen de los que cambiaron
# 3. Correr solo esos tests
# Implementación simple (sin herramientas avanzadas):
# - Cualquier cambio en :core:domain → correr TODOS los tests (todos dependen de él)
# - Cambio solo en :feature:checkout → correr solo :feature:checkout:test
# - Cambio en :core:network → correr :core:network:test + :feature:*:test
# Pipeline básico de CI:
# 1. ./gradlew :app:assembleDebug → verificar que compila
# 2. ./gradlew test → unit tests de todos los módulos
# 3. ./gradlew connectedAndroidTest → tests instrumentados (en schedule nocturno)
# Tiempos típicos con caché de Gradle remota (Gradle Enterprise):
# build incremental: 30-90 segundos
# unit tests completos: 2-5 minutos
# tests instrumentados: 10-30 minutos (solo en PR hacia main)