2026 में एक लोकल-फर्स्ट Android ऐप को टेस्ट करना: Room, Flow और वे Compose UI टेस्ट जो असली बग पकड़ते हैं
2026 में लोकल-फर्स्ट Android ऐप्स के लिए एक व्यावहारिक टेस्टिंग रणनीति — इन-मेमोरी Room DAO टेस्ट, Flow के लिए Turbine, और कुछ गिने-चुने Compose UI टेस्ट जो वाकई लिखने लायक हैं।
जब कोई सर्वर नहीं होता, तो कोई सर्वर-साइड टीम भी नहीं होती जो तुम्हारी गलतियों को शिप होने से पहले पकड़ ले। एक लोकल-फर्स्ट Android ऐप में हर गलती सीधे यूज़र के डिवाइस पर, उसके डेटाबेस में जाकर गिरती है, बिना किसी ऑप्स डैशबोर्ड के जो पहले इसे नोटिस करे। यही वह तर्क है जिस पर मैं बार-बार लौटता हूं यह समझाने के लिए कि क्यों एक लोकल-फर्स्ट ऐप को टेस्ट करना, ज़्यादातर API रिस्पॉन्स दिखाने वाले एक पतले क्लाइंट को टेस्ट करने से ज़्यादा ध्यान माँगता है — डेटाबेस लेयर ही प्रोडक्ट है, और उसके सामने बचाव की इकलौती लाइन मैं हूं।
यह वह टेस्टिंग सेटअप है जो मैं Granyn, Subly और बाकियों पर चलाता हूं — तीन लेयर, कोई भी विदेशी नहीं, सब इतने सस्ते कि इन्हें छोड़ने का कोई अच्छा बहाना नहीं बनता।
तीन लेयर जिन्हें मैं वाकई टेस्ट करता हूं
हर चीज़ एक जैसी सख्ती की हकदार नहीं होती। मैं टेस्ट को इस आधार पर बांटता हूं कि उस लेयर पर एक बग कितना महंगा पड़ेगा:
- DAO टेस्ट — हर क्वेरी, एक असली इन-मेमोरी Room डेटाबेस के खिलाफ। यहीं पैसा गलत गिना जाता है या कोई फ़िल्टर चुपचाप रो ड्रॉप कर देता है।
- Repository/Flow टेस्ट — DAO और UI के बीच बैठी मैपिंग और कॉम्बिनेशन लॉजिक। “DB में सही है पर स्क्रीन पुराना डेटा दिखा रही है” जैसे बग यहीं रहते हैं।
- मुट्ठी भर Compose UI टेस्ट — सिर्फ उन फ्लो के लिए जहां एक रिग्रेशन वाकई शर्मिंदगी भरा होगा, हर स्क्रीन के लिए नहीं।
बाकी सब कुछ — जो ViewModel सिर्फ डेलिगेट करते हैं, जिन DTO मैपर में कोई ब्रांचिंग नहीं है — छोड़ दिया जाता है। बिना लॉजिक वाले कोड को टेस्ट करना सुरक्षा नहीं, बस फ़ालतू काम है।
लेयर 1: इन-मेमोरी Room डेटाबेस के खिलाफ DAO टेस्ट
Room एक इन-मेमोरी डेटाबेस बिल्डर के साथ आता है जो ठीक इसी काम के लिए बना है। कोई मॉकिंग नहीं, कोई फ़ेक नहीं — असली SQL चलता है, बस डिस्क पर सेव नहीं होता:
@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)
}
}
यह अकेला टेस्ट एक असली बग पकड़ सकता था जो मैंने कभी शिप कर दिया था: WHERE currency = क्लॉज़ के बिना एक SUM() क्वेरी, जो चुपचाप यूरो को डॉलर में जोड़ रही थी। यह कोड रिव्यू में पास हो गया क्योंकि SQL सही दिख रहा था। यह उसी पल फेल हुआ जब एक टेस्ट ने असली नंबर पर assertion किया।
कुछ नियम जो इस लेयर को कीमती बनाए रखते हैं:
- क्वेरी को टेस्ट करो, फ्रेमवर्क को नहीं। ऐसा टेस्ट मत लिखो जो सिर्फ यह जांचे कि
insert()के बादgetById()वही रो लौटाता है — यह Room को टेस्ट करना है, तुम्हारे ऐप को नहीं। असली लॉजिक वाली क्वेरी टेस्ट करो: योग, फ़िल्टर, डेट-रेंज की सीमाएं, सॉर्ट ऑर्डर। - बॉर्डर रो वहीं हैं जहां बग छिपते हैं।
WHERE nextChargeDate <= :today— टेस्ट में एक ऐसी सब्सक्रिप्शन रखो जिसकी deadline ठीक आज हो, सिर्फ साफ तौर पर बीता हुआ या साफ तौर पर आने वाला केस नहीं। - माइग्रेशन का अपना टेस्ट होता है, जो एक्सपोर्ट की गई JSON से हर पुराना स्कीमा लोड करता है और
MigrationTestHelperको आगे की ओर चलाता है। यह किसी और पोस्ट लायक विषय है — इसका आधार बनने वाले exportSchema सेटअप के लिए लोकल-फर्स्ट Android पोस्ट देखो।
लेयर 2: Turbine के साथ Flow-आधारित रिपॉजिटरी टेस्ट
रिपॉजिटरी लेयर आमतौर पर दो या तीन Flow को जोड़ती है — जैसे DAO का आउटपुट और एक DataStore सेटिंग — और यह कॉम्बिनेशन लॉजिक ठीक उसी तरह का होता है जो लिखते वक्त साफ लगता है और छह महीने बाद किसी रीफैक्टर के बाद गलत निकलता है। Turbine, Flow एमिशन पर assertion करने को 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()
}
}
यहां वैल्यू Room को दोबारा टेस्ट करने में नहीं है — इस लेयर पर DAO को fake कर दिया जाता है, क्योंकि वह पहले ही कवर हो चुका है — बल्कि उन स्टेट्स के क्रम को टेस्ट करने में है जो UI वाकई देखेगा। एक Flow बग शायद ही कभी “गलत डेटा” के रूप में दिखता है, यह “सही डेटा, एक एमिशन देर से” के रूप में दिखता है, और इसे कोड पढ़कर पकड़ना, टेस्ट में एमिशन को गुज़रते देखने से कहीं ज़्यादा मुश्किल है।
लेयर 3: कुछ गिने-चुने Compose UI टेस्ट जो लिखने लायक हैं
मैं हर स्क्रीन को UI-टेस्ट नहीं करता — एक छोटे ऐप पर ज़्यादातर Compose UI टेस्टिंग एक ऐसी UI के खिलाफ कम-मूल्य वाला काम है जो टेस्ट का खर्च निकलने से पहले ही फिर से डिज़ाइन हो जाएगी। मैं इसे उन फ्लो के लिए बचाकर रखता हूं जहां एक चुपचाप हुआ रिग्रेशन वाकई महंगा पड़ता है:
- नोटिफ़िकेशन-एक्शन का रास्ता (taken / snooze / skip मार्क करना) — क्योंकि यहां एक टूटा हुआ टैप मतलब एक छूटी हुई डोज़, कोई कॉस्मेटिक गड़बड़ी नहीं।
- ऑनबोर्डिंग का परमिशन-रिक्वेस्ट स्टेप — क्योंकि यहां का रिग्रेशन मैनुअल टेस्टिंग में अदृश्य होता है (तुम, डेवलपर, महीनों पहले ही हर परमिशन दे चुके हो) और एक नए यूज़र के पहले पांच मिनटों के लिए घातक।
@Test
fun markingDoseTaken_removesItFromDueList() {
composeTestRule.setContent { DoseListScreen(state = dueDoseState) }
composeTestRule.onNodeWithText("Metformin — 500mg").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Mark taken").performClick()
composeTestRule.onNodeWithText("Metformin — 500mg").assertDoesNotExist()
}
अकेले मेंटेन किए जा रहे ऐप के लिए UI टेस्ट का पूरा बजट बस इतना ही है: उन रास्तों पर मुट्ठी भर टेस्ट जहां “चुपचाप काम करना बंद कर देना” सबसे बुरा नतीजा है, हर पिक्सल कवर करने की कोशिश करने वाली कोई सुइट नहीं।
जो मैं जानबूझकर टेस्ट नहीं करता
इन्हें छोड़ना आलस नहीं है, यह वह जगह है जहां समय का रिटर्न वाकई शून्य हो जाता है:
- बिना लॉजिक वाले गेटर/सेटर और डेटा क्लास। टूटने के लिए कुछ है ही नहीं।
- Compose प्रीव्यू और शुद्ध लेआउट। एक स्क्रीनशॉट-डिफ़ टूल इसे assertion-आधारित टेस्ट से कहीं बेहतर पकड़ता है, और इस स्केल पर एक जोड़ने का ROI मुझे अब तक नहीं मिला।
- थर्ड-पार्टी लाइब्रेरी के अंदरूनी हिस्से। Room के क्वेरी एक्ज़ीक्यूशन पर भरोसा करो; अपने SQL को टेस्ट करो, SQLite को नहीं।
हासिल क्या होता है
इनमें से कोई भी एक बड़ी टेस्ट सुइट नहीं है — असली लॉजिक वाली क्वेरी के लिए DAO टेस्ट, स्टेट जोड़ने वाले Flow के लिए एक दर्जन Turbine टेस्ट, और चुप्पी जहां महंगी पड़ती है उन रास्तों के लिए मुट्ठी भर Compose टेस्ट। यह जो खरीदता है वह ठोस है: जब भी मैं किसी क्वेरी को छूता हूं या किसी रिपॉजिटरी को रीफैक्टर करता हूं, फेलियर कुछ सेकंड में एक लाल टेस्ट के रूप में सामने आ जाता है — तीन हफ्ते बाद उस किसी की सपोर्ट ईमेल के रूप में नहीं जिसकी सब्सक्रिप्शन का टोटल ठीक एक यूरो-वाली रो जितना गलत था।
एक अकेले इंसान की दुकान के लिए, यही टेस्टिंग का असली मकसद है — यह साबित करना नहीं कि कोड किसी अमूर्त अर्थ में सही है, बल्कि यह पक्का करना कि जब कुछ टूटे, तो सबसे पहले पता तुम्हें ही चले।
// संबंधित पठन
जर्नल से और भी
2026 में Room TypeConverters: अपने स्कीमा को खराब किए बिना एनम, डेट, और लिस्ट स्टोर करना
Android पर Room TypeConverters के लिए एक व्यावहारिक गाइड — एनम, Instant/LocalDate, और लिस्ट — साथ ही वे ग़लतियाँ जो एक कनवर्टर को एक चुपचाप डेटा-करप्शन बग में बदल देती हैं।
Kotlin, Room और Compose के लिए R8 और ProGuard: वह क्रैश जो सिर्फ़ रिलीज़ में होता है
एक Room + Compose Android ऐप डिबग में बिल्कुल ठीक क्यों चलता है और प्रोडक्शन में क्रैश क्यों होता है, और वे ख़ास R8/ProGuard keep नियम जो इसे यूज़र से पहले पकड़ लेते हैं।
Android का In-App Review API: बिना परेशान किए रेटिंग माँगना
Google के In-App Review API की एक व्यावहारिक गाइड — यह असल में कैसे काम करता है, इसे कब ट्रिगर करना चाहिए, और सामान्य 'हमें रेट करें' पॉपअप आपकी Play Store रेटिंग को चुपचाप कैसे नुकसान पहुँचा रहा है।