İçeriğe geç
Tüm yazılar

2026'da local-first bir Android uygulamasını test etmek: gerçek hataları yakalayan Room, Flow ve Compose UI testleri

2026'da local-first Android uygulamaları için pratik bir test stratejisi — bellek içi Room DAO testleri, Flow için Turbine ve yazmaya gerçekten değen birkaç Compose UI testi.

MFKAPPS 5 dk okuma

Sunucu olmadığında, hatalarını sen yayınlamadan önce yakalayacak bir sunucu tarafı ekip de olmaz. Local-first bir Android uygulamasındaki her hata, önce bir bilgi ekranında fark edilmeden, doğrudan kullanıcının cihazındaki veritabanına düşer. Local-first bir uygulamayı test etmenin, çoğunlukla API yanıtlarını ekrana basan ince bir istemciyi test etmekten neden daha fazla özen gerektirdiği konusunda sürekli döndüğüm argüman bu — veritabanı katmanı ürünün kendisidir ve onun önündeki tek savunma hattı benim.

Bu, Granyn, Subly ve diğerlerinde çalıştırdığım test kurulumu — üç katman, hiçbiri egzotik değil, atlamak için iyi bir bahane olmayacak kadar hepsi ucuz.

Aslında test ettiğim üç katman

Her şey aynı titizliği hak etmiyor. O katmandaki bir hatanın maliyetine göre testleri ayırıyorum:

  1. DAO testleri — her sorgu, gerçek bir bellek içi Room veritabanına karşı. Paranın yanlış sayıldığı ya da bir filtrenin satırları sessizce düşürdüğü yer burası.
  2. Repository/Flow testleri — DAO ile arayüz arasında oturan eşleme ve birleştirme mantığı. “Veritabanında çalışıyor ama ekran eski veriyi gösteriyor” hataları burada yaşıyor.
  3. Bir avuç Compose UI testi — yalnızca bir regresyonun utandırıcı olacağı akışlar için, her ekran için değil.

Geri kalan her şey — sadece devreden geçen ViewModel’ler, dallanma içermeyen DTO eşleyicileri — atlanıyor. İçinde mantık olmayan kodu test etmek güvenlik değil, gereksiz iştir.

Katman 1: bellek içi bir Room veritabanına karşı DAO testleri

Room, tam olarak bunun için tasarlanmış bir bellek içi veritabanı builder’ıyla geliyor. Mock yok, fake yok — gerçek SQL çalışır, sadece diske kalıcı olarak yazılmaz:

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

Bu tek test, bir keresinde yayınladığım gerçek bir hatayı yakalayabilirdi: WHERE currency = koşulu olmadan bir SUM() sorgusu, sessizce euroları dolarlara ekliyordu. Kod incelemesinden geçti çünkü SQL doğru görünüyordu. Bir test gerçek sayı üzerinde assertion yaptığı an başarısız oldu.

Bu katmanı değerli tutan birkaç kural:

  • Sorguyu test et, framework’ü değil. insert() sonrası getById()’nin aynı satırı döndürdüğünü doğrulayan bir test yazma — bu Room’u test etmektir, uygulamanı değil. Gerçek mantığı olan sorguları test et: toplamlar, filtreler, tarih aralığı sınırları, sıralama.
  • Sınır satırları hataların saklandığı yerdir. WHERE nextChargeDate <= :today — testi, açıkça geçmiş ve açıkça gelecek olanlarla değil, tam olarak bugün vadesi gelen bir abonelikle yaz.
  • Migration’lar kendi testlerini alır, dışa aktarılan JSON’dan her tarihsel şemayı yükleyip MigrationTestHelper’ı ileri doğru çalıştırarak. Bu, başka bir yazının konusu — bunun dayandığı exportSchema kurulumu için local-first Android yazısına bak.

Katman 2: Turbine ile Flow tabanlı repository testleri

Repository katmanı genellikle iki ya da üç Flow’u birleştirir — mesela DAO çıktısı artı bir DataStore ayarı — ve bu birleştirme mantığı, yazarken açık görünüp altı ay sonra bir refactor’dan sonra yanlış çıkan türden bir şeydir. Turbine, Flow emisyonları üzerinde assertion yapmayı, toplayıcılardan oluşan bir runBlocking karmaşası yerine okunabilir hale getiriyor:

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

Buradaki değer Room’u tekrar test etmek değil — DAO bu katmanda fake’lenir, çünkü o zaten kapsanmıştır — arayüzün gerçekte göreceği durumların sırasını test etmektir. Bir Flow hatası nadiren “yanlış veri” olarak ortaya çıkar, “doğru veri, bir emisyon geç” olarak ortaya çıkar ve bunu kodu okuyarak yakalamak, testte emisyonların geçişini izleyerek yakalamaktan çok daha zordur.

Katman 3: yazmaya değen birkaç Compose UI testi

Her ekranı UI testine tabi tutmuyorum — küçük bir uygulamada Compose UI testinin çoğu, testin kendini amorti etmesinden önce yeniden tasarlanacak bir arayüze karşı düşük değerli bir uğraştır. Bunu, sessiz bir regresyonun gerçekten maliyetli olduğu akışlar için saklıyorum:

  • Bildirim eylemi yolu (taken / snooze / skip) — çünkü buradaki bozuk bir dokunma kozmetik bir aksaklık değil, kaçırılan bir doz demektir.
  • Onboarding’in izin isteme adımı — çünkü buradaki bir regresyon, manuel testte görünmez (sen, geliştirici, her izni aylar önce zaten verdin) ve yeni bir kullanıcının ilk beş dakikası için ölümcüldür.
@Test
fun markingDoseTaken_removesItFromDueList() {
    composeTestRule.setContent { DoseListScreen(state = dueDoseState) }

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

Solo bakımı yapılan bir uygulamada UI testleri için tüm bütçe bu kadar: “sessizce çalışmayı bıraktı”nın olabilecek en kötü sonuç olduğu yollarda bir avuç test, her pikseli kapsamaya çalışan bir suit değil.

Bilinçli olarak test etmediklerim

Bunları atlamak tembellik değil, zamanın getirisinin gerçekten sıfıra indiği yer:

  • Mantık içermeyen getter/setter’lar ve data class’lar. Bozulacak bir şey yok.
  • Compose preview’ları ve saf layout. Bir screenshot-diff aracı bunu assertion tabanlı bir testten çok daha iyi yakalar ve bu ölçekte henüz eklemeye değecek bir ROI bulamadım.
  • Üçüncü taraf kütüphane iç yapıları. Room’un sorgu yürütmesine güven; SQLite’ı değil, kendi SQL’ini test et.

Karşılığı

Bunların hiçbiri büyük bir test suit’i değil — gerçek mantığı olan sorgular için DAO testleri, durumu birleştiren Flow’lar için bir düzine Turbine testi ve sessizliğin pahalı olduğu yollar için bir avuç Compose testi. Bunun karşılığında aldığın şey somut: bir sorguya her dokunduğumda ya da bir repository’yi refactor ettiğimde, hata birkaç saniye içinde kırmızı bir test olarak ortaya çıkıyor — üç hafta sonra abonelik toplamı tam olarak bir euro cinsinden satır kadar yanlış olan birinden gelen bir destek e-postası olarak değil.

Tek kişilik bir dükkân için testin gerçek amacı bu — kodun soyut bir anlamda doğru olduğunu kanıtlamak değil, bir şey bozulduğunda bunu ilk fark eden kişinin sen olmanı sağlamak.