Base de datos in-memory — el setup correcto

Room puede crear una base de datos en memoria — no escribe nada en disco, existe solo mientras dura el test, y es mucho más rápida que una base de datos real. Es la herramienta estándar para tests de DAO.

// Los tests de Room son instrumentados (src/androidTest/)
// porque Room necesita el contexto de Android para compilar el SQLite
@RunWith(AndroidJUnit4::class)
class ProductoDaoTest {

    private lateinit var db: AppDatabase
    private lateinit var dao: ProductoDao

    @Before
    fun setUp() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(
            context,
            AppDatabase::class.java
        )
        .allowMainThreadQueries()  // permitir en main thread para simplificar los tests
        .build()

        dao = db.productoDao()
    }

    @After
    fun tearDown() {
        db.close()  // limpiar la base in-memory después de cada test
    }
}

allowMainThreadQueries() solo en testsallowMainThreadQueries() permite queries síncronas en el main thread. Solo se usa en tests — nunca en producción. En producción, Room fuerza que las queries estén en coroutines o en background threads.

Tests de DAO — casos típicos

@Test
fun `insertar y leer un producto`() = runTest {
    val producto = ProductoEntity(id = 1, nombre = "Teclado", precio = 5000.0)

    dao.insertar(producto)

    val resultado = dao.getById(1)
    assertThat(resultado).isNotNull()
    assertThat(resultado?.nombre).isEqualTo("Teclado")
    assertThat(resultado?.precio).isEqualTo(5000.0)
}

@Test
fun `insertar lista y obtener todos`() = runTest {
    val productos = listOf(
        ProductoEntity(1, "Mouse", 2000.0),
        ProductoEntity(2, "Teclado", 5000.0),
        ProductoEntity(3, "Monitor", 80000.0)
    )

    dao.insertarTodos(productos)

    val resultado = dao.getTodos()
    assertThat(resultado).hasSize(3)
}

@Test
fun `actualizar un producto`() = runTest {
    dao.insertar(ProductoEntity(1, "Mouse", 2000.0))

    dao.insertar(ProductoEntity(1, "Mouse Inalámbrico", 3500.0))  // mismo id = reemplaza

    val resultado = dao.getById(1)
    assertThat(resultado?.nombre).isEqualTo("Mouse Inalámbrico")
    assertThat(resultado?.precio).isEqualTo(3500.0)
}

@Test
fun `eliminar un producto`() = runTest {
    dao.insertar(ProductoEntity(1, "Mouse", 2000.0))
    dao.insertar(ProductoEntity(2, "Teclado", 5000.0))

    dao.eliminarById(1)

    assertThat(dao.getById(1)).isNull()
    assertThat(dao.getById(2)).isNotNull()
    assertThat(dao.getTodos()).hasSize(1)
}

@Test
fun `buscar por nombre retorna coincidencias`() = runTest {
    dao.insertarTodos(listOf(
        ProductoEntity(1, "Mouse USB", 2000.0),
        ProductoEntity(2, "Mouse Inalámbrico", 3500.0),
        ProductoEntity(3, "Teclado", 5000.0)
    ))

    val resultado = dao.buscarPorNombre("mouse")

    assertThat(resultado).hasSize(2)
    assertThat(resultado.map { it.nombre }).containsExactly("Mouse USB", "Mouse Inalámbrico")
}

@Test
fun `obtener producto inexistente retorna null`() = runTest {
    val resultado = dao.getById(999)
    assertThat(resultado).isNull()
}

@Test
fun `base vacia retorna lista vacia`() = runTest {
    val resultado = dao.getTodos()
    assertThat(resultado).isEmpty()
}

Testear DAOs con Flow

@Test
fun `observar productos emite cambios en tiempo real`() = runTest {
    // Usar Turbine para capturar emisiones del Flow
    dao.observarTodos().test {
        // Emisión inicial — lista vacía
        assertThat(awaitItem()).isEmpty()

        // Insertar datos — debe emitir de nuevo
        dao.insertar(ProductoEntity(1, "Mouse", 2000.0))
        assertThat(awaitItem()).hasSize(1)

        // Insertar otro — nueva emisión
        dao.insertar(ProductoEntity(2, "Teclado", 5000.0))
        assertThat(awaitItem()).hasSize(2)

        // Eliminar — nueva emisión
        dao.eliminarById(1)
        val restantes = awaitItem()
        assertThat(restantes).hasSize(1)
        assertThat(restantes[0].id).isEqualTo(2)

        cancelAndIgnoreRemainingEvents()
    }
}

Testear migraciones con MigrationTestHelper

// Requiere exportSchema = true y schemas en el repo (ver artículo de migraciones)
@RunWith(AndroidJUnit4::class)
class MigracionesTest {

    @get:Rule
    val helper = MigrationTestHelper(
        InstrumentationRegistry.getInstrumentation(),
        AppDatabase::class.java  // Room encuentra los schemas en assets
    )

    @Test
    fun `migrar de version 1 a 2 agrega columna descripcion`() {
        // 1. Crear la base en la versión 1 con datos
        helper.createDatabase("test_db", 1).use { db ->
            db.execSQL(
                "INSERT INTO productos (id, nombre, precio) VALUES (1, 'Mouse', 2000)"
            )
        }

        // 2. Aplicar la migración y validar el schema
        val db = helper.runMigrationsAndValidate("test_db", 2, true, MIGRATION_1_2)

        // 3. Verificar que los datos siguen y la nueva columna existe
        val cursor = db.query("SELECT * FROM productos WHERE id = 1")
        cursor.use {
            assertThat(it.moveToFirst()).isTrue()
            assertThat(it.getColumnIndex("descripcion")).isNotEqualTo(-1)
            assertThat(it.getString(it.getColumnIndexOrThrow("nombre"))).isEqualTo("Mouse")
        }

        db.close()
    }

    @Test
    fun `migrar de version 1 a 3 aplicando dos migraciones`() {
        helper.createDatabase("test_db", 1).use { db ->
            db.execSQL("INSERT INTO productos VALUES (1, 'Mouse', 2000)")
        }

        // Room aplica 1→2 y 2→3 en secuencia
        val db = helper.runMigrationsAndValidate(
            "test_db", 3, true,
            MIGRATION_1_2, MIGRATION_2_3
        )

        // El dato debe sobrevivir ambas migraciones
        val cursor = db.query("SELECT id FROM productos WHERE id = 1")
        assertThat(cursor.count).isEqualTo(1)
        cursor.close()
        db.close()
    }
}

Tests del Repository — integración Room + lógica

// Test de integración: Repository real + Room in-memory (sin API real)
@RunWith(AndroidJUnit4::class)
class ProductoRepositoryTest {

    private lateinit var db: AppDatabase
    private lateinit var repository: ProductoRepositoryImpl
    private val mockApi = mockk<ProductoApi>()

    @Before
    fun setUp() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java)
            .allowMainThreadQueries()
            .build()
        repository = ProductoRepositoryImpl(mockApi, db.productoDao())
    }

    @After fun tearDown() { db.close() }

    @Test
    fun `sync guarda datos de la API en Room`() = runTest {
        val productosApi = listOf(
            ProductoDto(id = 1, nombre = "Mouse", precio = 2000.0),
            ProductoDto(id = 2, nombre = "Teclado", precio = 5000.0)
        )
        coEvery { mockApi.getProductos() } returns productosApi

        repository.sync()

        val enDb = db.productoDao().getTodos()
        assertThat(enDb).hasSize(2)
        assertThat(enDb.map { it.nombre }).containsExactly("Mouse", "Teclado")
    }

    @Test
    fun `getProductosFlow emite datos de Room sin llamar a la API`() = runTest {
        db.productoDao().insertarTodos(listOf(
            ProductoEntity(1, "Mouse", 2000.0),
            ProductoEntity(2, "Teclado", 5000.0)
        ))

        repository.getProductosFlow().test {
            val productos = awaitItem()
            assertThat(productos).hasSize(2)
            cancelAndIgnoreRemainingEvents()
        }

        // La API NO fue llamada — los datos vienen del caché local
        coVerify(exactly = 0) { mockApi.getProductos() }
    }
}

Tests rápidos vs completos — la estrategia correcta

# Los tests instrumentados son lentos (30s - varios minutos por suite completa)
# Estrategia para mantenerlos manejables:

# 1. Usá in-memory database — 10x más rápido que disco real
# 2. Limitá los tests de Room a lo que Room hace:
#    → queries, filtros, joins, relaciones, constraints
#    → NO testees lógica de negocio en tests de DAO

# 3. Para tests de repositorio, usá FakeDao cuando puedas:
#    → Así los tests del repositorio corren en JVM (src/test/) sin emulador
#    → Solo usá la base real cuando el test verifica el comportamiento SQL

# 4. Separar suites de tests:
./gradlew test                  # solo unit tests (rápidos, sin dispositivo)
./gradlew connectedAndroidTest  # todos los tests instrumentados

# 5. En CI: siempre correr unit tests; correr instrumentados en schedule nocturno
#    o en PRs hacia main — no en cada commit