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)