Aller au contenu
Tous les articles

Tester une application Android local-first en 2026 : Room, Flow et les tests Compose UI qui détectent de vrais bugs

Une stratégie de test pratique pour les applications Android local-first en 2026 — tests DAO Room en mémoire, Turbine pour Flow, et les quelques tests Compose UI qui valent vraiment la peine.

MFKAPPS 6 min de lecture

Sans serveur, il n’y a pas d’équipe côté serveur pour détecter tes bugs avant qu’ils ne partent en production. Chaque erreur dans une application Android local-first atterrit directement dans la base de données d’un utilisateur, sur son appareil, sans tableau de bord d’exploitation pour la remarquer en premier. C’est l’argument auquel je reviens toujours pour expliquer pourquoi tester une application local-first mérite plus de soin que tester un client léger qui se contente d’afficher des réponses d’API — la couche base de données est le produit, et je suis la seule ligne de défense devant elle.

Voici la configuration de tests que j’utilise sur Granyn, Subly et le reste — trois couches, aucune exotique, toutes assez peu coûteuses pour qu’il n’y ait aucune bonne excuse de les sauter.

Les trois couches que je teste réellement

Tout ne mérite pas la même rigueur. Je répartis les tests selon le coût qu’aurait un bug à cette couche :

  1. Tests DAO — chaque requête, contre une vraie base de données Room en mémoire. C’est là que l’argent est mal compté ou qu’un filtre fait silencieusement disparaître des lignes.
  2. Tests repository/Flow — la logique de mapping et de combinaison entre le DAO et l’UI. C’est là que vivent les bugs du genre « ça marche en base mais l’écran affiche des données périmées ».
  3. Une poignée de tests Compose UI — uniquement pour les parcours où une régression serait vraiment embarrassante, pas pour chaque écran.

Tout le reste — les ViewModels qui se contentent de déléguer, les mappeurs de DTO sans branchement — est laissé de côté. Tester du code sans logique, c’est de l’occupation, pas de la sécurité.

Couche 1 : tests DAO contre une base de données Room en mémoire

Room est livré avec un builder de base de données en mémoire conçu exactement pour ça. Pas de mocking, pas de fakes — le vrai SQL s’exécute, simplement sans être persisté sur disque :

@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)
    }
}

Ce seul test aurait détecté un vrai bug que j’ai publié un jour : une requête SUM() sans clause WHERE currency =, ajoutant silencieusement des euros à des dollars. Il est passé la revue de code parce que le SQL avait l’air correct. Il a échoué dès qu’un test a fait une assertion sur le chiffre réel.

Quelques règles qui gardent cette couche utile :

  • Teste la requête, pas le framework. N’écris pas un test qui vérifie qu’un insert() suivi d’un getById() renvoie la même ligne — ça teste Room, pas ton application. Teste les requêtes avec une vraie logique : sommes, filtres, bornes de plages de dates, ordre de tri.
  • Les lignes limites sont là où se cachent les bugs. WHERE nextChargeDate <= :today — écris le test avec un abonnement dont l’échéance est exactement aujourd’hui, pas seulement des cas clairement passés ou clairement futurs.
  • Les migrations ont leur propre test, en chargeant chaque schéma historique depuis le JSON exporté et en exécutant MigrationTestHelper vers l’avant. C’est le sujet d’un autre article — voir l’article sur le local-first Android pour la configuration exportSchema dont ceci dépend.

Couche 2 : tests repository basés sur Flow avec Turbine

La couche repository combine généralement deux ou trois Flow — la sortie du DAO plus un réglage DataStore, par exemple — et cette logique de combinaison est exactement le genre de chose évidente au moment de l’écrire et fausse six mois plus tard après un refactor. Turbine rend les assertions sur les émissions de Flow lisibles au lieu d’un fouillis de collecteurs 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()
    }
}

L’intérêt ici n’est pas de retester Room — le DAO est fake à cette couche, puisqu’il est déjà couvert — mais de tester la séquence d’états que l’UI verra réellement. Un bug de Flow se manifeste rarement comme « mauvaise donnée » ; il se manifeste comme « bonne donnée, une émission trop tard », et c’est bien plus difficile à repérer en lisant le code qu’en observant les émissions défiler dans un test.

Couche 3 : les quelques tests Compose UI qui valent la peine

Je ne teste pas chaque écran en UI — sur une petite application, la majorité des tests Compose UI est un travail à faible valeur contre une interface qui sera redessinée avant que le test ne se rentabilise. Je réserve ça aux parcours où une régression silencieuse coûte vraiment cher :

  • Le chemin de l’action de notification (marquer pris / reporter / passer) — parce qu’un tap cassé ici signifie une dose manquée, pas un simple bug cosmétique.
  • L’étape de demande de permission dans l’onboarding — parce qu’une régression ici est invisible en test manuel (toi, le développeur, as déjà accordé chaque permission il y a des mois) et fatale aux cinq premières minutes d’un nouvel utilisateur.
@Test
fun markingDoseTaken_removesItFromDueList() {
    composeTestRule.setContent { DoseListScreen(state = dueDoseState) }

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

C’est tout le budget de tests UI pour une application maintenue en solo : une poignée de tests sur les parcours où « ça s’est arrêté de fonctionner silencieusement » est le pire résultat possible, pas une suite qui essaie de couvrir chaque pixel.

Ce que je ne teste délibérément pas

Sauter ces éléments n’est pas de la paresse, c’est là où le retour sur le temps investi tombe réellement à zéro :

  • Les getters/setters et les data classes sans logique. Rien à casser.
  • Les previews Compose et le layout pur. Un outil de diff par capture d’écran attrape ça bien mieux qu’un test à base d’assertions, et je n’ai pas encore trouvé le retour sur investissement pour en ajouter un à cette échelle.
  • Les internes des bibliothèques tierces. Fais confiance à l’exécution des requêtes de Room ; teste ton SQL, pas SQLite.

Le gain

Rien de tout cela ne constitue une grosse suite de tests — des tests DAO pour les requêtes avec une vraie logique, une dizaine de tests Turbine pour les Flow qui combinent des états, et une poignée de tests Compose pour les parcours où le silence coûte cher. Ce que ça achète est précis : à chaque fois que je touche une requête ou que je refactorise un repository, l’échec apparaît sous forme de test rouge en quelques secondes, pas sous forme d’email de support trois semaines plus tard de quelqu’un dont le total d’abonnement était faux exactement du montant d’une ligne en euros.

Pour une boutique d’une seule personne, c’est ça le véritable but des tests — pas prouver que le code est correct dans un sens abstrait, mais s’assurer que quand quelque chose casse, c’est toi qui le découvres en premier.