Saltar al contenido
Todas las entradas

Probar una app Android local-first en 2026: Room, Flow y los tests de Compose UI que detectan errores reales

Una estrategia práctica de testing para apps Android local-first en 2026 — tests de DAO con Room en memoria, Turbine para Flow, y los pocos tests de Compose UI que de verdad valen la pena.

MFKAPPS 6 min de lectura

Cuando no hay servidor, no hay equipo del lado del servidor que detecte tus errores antes de que salgan a producción. Cada error en una app Android local-first aterriza directamente en la base de datos de un usuario, en su dispositivo, sin ningún panel de operaciones que lo note primero. Ese es el argumento al que siempre vuelvo para explicar por qué probar una app local-first merece más cuidado que probar un cliente ligero que básicamente muestra respuestas de una API — la capa de base de datos es el producto, y yo soy la única línea de defensa delante de ella.

Esta es la configuración de tests que uso en Granyn, Subly y el resto — tres capas, ninguna exótica, todas lo bastante baratas como para que saltárselas no tenga ninguna buena excusa.

Las tres capas que realmente pruebo

No todo merece el mismo rigor. Divido los tests según cuánto costaría un error en esa capa:

  1. Tests de DAO — cada consulta, contra una base de datos Room real en memoria. Aquí es donde el dinero se cuenta mal o un filtro descarta filas en silencio.
  2. Tests de repository/Flow — la lógica de mapeo y combinación entre el DAO y la UI. Aquí viven los errores del tipo “funciona en la base de datos pero la pantalla muestra datos obsoletos”.
  3. Un puñado de tests de Compose UI — solo para flujos donde una regresión sería realmente embarazosa, no para cada pantalla.

Todo lo demás — ViewModels que solo delegan, mappers de DTO sin ramificaciones — se deja de lado. Probar código sin lógica es ocupación, no seguridad.

Capa 1: tests de DAO contra una base de datos Room en memoria

Room viene con un builder de base de datos en memoria hecho exactamente para esto. Sin mocking, sin fakes — el SQL real se ejecuta, solo que no se persiste en disco:

@RunWith(AndroidJUnit4::class)
class SubscriptionDaoTest {
    private lateinit var db: AppDatabase
    private lateinit var dao: SubscriptionDao

    @Before
    fun setup() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            AppDatabase::class.java,
        ).build()
        dao = db.subscriptionDao()
    }

    @After
    fun teardown() = db.close()

    @Test
    fun totalForCurrency_sumsOnlyMatchingRows() = runTest {
        dao.insert(subscription(amountMinor = 999, currency = "USD"))
        dao.insert(subscription(amountMinor = 500, currency = "EUR"))
        dao.insert(subscription(amountMinor = 1200, currency = "USD"))

        val total = dao.totalForCurrency("USD").first()

        assertEquals(2199L, total)
    }
}

Este único test habría detectado un error real que publiqué una vez: una consulta SUM() sin cláusula WHERE currency =, sumando silenciosamente euros a dólares. Pasó la revisión de código porque el SQL parecía correcto. Falló en cuanto un test hizo una aserción sobre el número real.

Algunas reglas que mantienen esta capa valiosa:

  • Prueba la consulta, no el framework. No escribas un test que verifique que un insert() seguido de un getById() devuelve la misma fila — eso prueba Room, no tu app. Prueba las consultas con lógica real: sumas, filtros, límites de rangos de fechas, orden de clasificación.
  • Las filas límite son donde se esconden los errores. WHERE nextChargeDate <= :today — escribe el test con una suscripción que vence exactamente hoy, no solo casos claramente pasados o claramente futuros.
  • Las migraciones tienen su propio test, cargando cada esquema histórico desde el JSON exportado y ejecutando MigrationTestHelper hacia adelante. Esto merece otro artículo — mira el artículo sobre local-first en Android para la configuración de exportSchema de la que depende esto.

Capa 2: tests de repository basados en Flow con Turbine

La capa de repository normalmente combina dos o tres Flow — la salida del DAO más un ajuste de DataStore, por ejemplo — y esa lógica de combinación es justo el tipo de cosa que es obvia al escribirla y está mal seis meses después, tras un refactor. Turbine hace que las aserciones sobre emisiones de Flow sean legibles en lugar de un lío de colectores en runBlocking:

@Test
fun observeDueToday_excludesSkippedDoses() = runTest {
    val repository = DoseRepository(fakeDao, fakeClock)

    repository.observeDueToday().test {
        assertEquals(emptyList(), awaitItem())

        fakeDao.insert(dose(status = PENDING, time = today9am))
        assertEquals(1, awaitItem().size)

        fakeDao.updateStatus(doseId, SKIPPED)
        assertEquals(0, awaitItem().size)

        cancelAndConsumeRemainingEvents()
    }
}

El valor aquí no es volver a probar Room — el DAO se falsea en esta capa, ya que eso ya está cubierto — es probar la secuencia de estados que la UI realmente verá. Un error de Flow rara vez aparece como “dato incorrecto”; aparece como “dato correcto, una emisión tarde”, y eso es mucho más difícil de detectar leyendo el código que observando pasar las emisiones en un test.

Capa 3: los pocos tests de Compose UI que valen la pena

No pruebo la UI de cada pantalla — en una app pequeña, la mayoría de los tests de Compose UI son trabajo de bajo valor contra una interfaz que se rediseñará antes de que el test se amortice. Los reservo para flujos donde una regresión silenciosa es realmente costosa:

  • El flujo de la acción de notificación (marcar tomado / posponer / omitir) — porque un toque roto aquí significa una dosis perdida, no un fallo cosmético.
  • El paso de solicitud de permisos en el onboarding — porque una regresión aquí es invisible en pruebas manuales (tú, el desarrollador, ya concediste cada permiso hace meses) y fatal para los primeros cinco minutos de un usuario nuevo.
@Test
fun markingDoseTaken_removesItFromDueList() {
    composeTestRule.setContent { DoseListScreen(state = dueDoseState) }

    composeTestRule.onNodeWithText("Metformin — 500mg").assertIsDisplayed()
    composeTestRule.onNodeWithContentDescription("Mark taken").performClick()
    composeTestRule.onNodeWithText("Metformin — 500mg").assertDoesNotExist()
}

Ese es todo el presupuesto de tests de UI para una app mantenida en solitario: un puñado de tests en los flujos donde “dejó de funcionar en silencio” es el peor resultado posible, no una suite que intenta cubrir cada píxel.

Lo que deliberadamente no pruebo

Saltarse esto no es pereza, es donde el retorno del tiempo invertido realmente llega a cero:

  • Getters/setters y data classes sin lógica. No hay nada que romper.
  • Previews de Compose y layout puro. Una herramienta de diff de capturas de pantalla detecta esto mucho mejor que un test basado en aserciones, y todavía no he encontrado el ROI para añadir una a esta escala.
  • Los internos de librerías de terceros. Confía en la ejecución de consultas de Room; prueba tu SQL, no SQLite.

La recompensa

Nada de esto es una suite de tests grande — tests de DAO para las consultas con lógica real, una docena de tests de Turbine para los Flow que combinan estado, y un puñado de tests de Compose para los flujos donde el silencio sale caro. Lo que compra es concreto: cada vez que toco una consulta o refactorizo un repository, el fallo aparece como un test en rojo en segundos, no como un correo de soporte tres semanas después de alguien cuyo total de suscripciones estaba mal por exactamente el importe de una fila en euros.

Para un negocio de una sola persona, ese es el verdadero propósito de los tests — no demostrar que el código es correcto en algún sentido abstracto, sino asegurarte de que, cuando algo se rompe, seas tú quien se entera primero.