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.
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:
- 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ı.
- 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.
- 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.
// İlgili okumalar
Günlükten dahası
2026'da Android'de Room TypeConverters: şemanı bozmadan enum, tarih ve liste saklamak
Android'de Room TypeConverters için pratik bir rehber — enum'lar, Instant/LocalDate ve listeler — ve bir converter'ı sessiz bir veri bozulması hatasına dönüştüren hatalar.
Kotlin, Room ve Compose için R8 ve ProGuard: yalnızca release'te olan çökme
Bir Room + Compose Android uygulaması debug'da neden kusursuz çalışıp üretimde neden çöküyor ve bunu bir kullanıcı bulmadan önce yakalayan spesifik R8/ProGuard keep kuralları.
Android In-App Review API: sıkıcı olmadan puan istemek
Google'ın In-App Review API'sine pratik bir rehber — gerçekte nasıl çalıştığı, ne zaman tetiklenmesi gerektiği ve alışılmış 'bizi puanlayın' popup'ının Play Store puanınızı sessizce nasıl zedelediği.