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