Тестирование local-first Android-приложения в 2026 году: Room, Flow и Compose UI-тесты, которые ловят реальные баги
Практическая стратегия тестирования для local-first Android-приложений в 2026 году — DAO-тесты с Room в памяти, Turbine для Flow и те немногие Compose UI-тесты, которые действительно стоит писать.
Когда нет сервера, нет и серверной команды, которая поймает твои баги до того, как они уйдут в продакшен. Каждая ошибка в local-first Android-приложении приземляется прямо в базу данных пользователя, на его устройстве, без какой-либо операционной панели, которая заметит это первой. Это тот аргумент, к которому я постоянно возвращаюсь, объясняя, почему тестирование local-first приложения заслуживает большего внимания, чем тестирование тонкого клиента, который в основном просто отображает ответы API — слой базы данных и есть продукт, а я — единственная линия защиты перед ним.
Вот конфигурация тестов, которую я использую в Granyn, Subly и остальных — три уровня, ни один не экзотический, все достаточно дёшевы, чтобы не было оправдания их пропускать.
Три уровня, которые я реально тестирую
Не всё заслуживает одинаковой строгости. Я делю тесты по тому, насколько дорогим будет баг на этом уровне:
- DAO-тесты — каждый запрос, против настоящей базы данных Room в памяти. Именно здесь деньги считаются неправильно или фильтр молча отбрасывает строки.
- Repository/Flow-тесты — логика маппинга и комбинирования между DAO и UI. Именно здесь живут баги вида «в БД всё правильно, но экран показывает устаревшие данные».
- Горстка Compose UI-тестов — только для потоков, где регрессия будет по-настоящему неловкой, а не для каждого экрана.
Всё остальное — ViewModel’и, которые просто делегируют, DTO-мапперы без ветвления — пропускается. Тестировать код без логики — это не безопасность, а лишняя работа.
Уровень 1: DAO-тесты против базы данных Room в памяти
Room поставляется со сборщиком базы данных в памяти, созданным именно для этого. Никакого мокинга, никаких fake — выполняется настоящий 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)
}
}
Этот единственный тест поймал бы реальный баг, который я однажды выпустил в продакшен: запрос SUM() без условия WHERE currency =, молча складывающий евро с долларами. Он прошёл ревью кода, потому что SQL выглядел правильным. Он упал в тот момент, когда тест сделал проверку на реальном числе.
Несколько правил, которые сохраняют ценность этого уровня:
- Тестируй запрос, а не фреймворк. Не пиши тест, который просто проверяет, что
insert(), за которым следуетgetById(), возвращает ту же строку — это тестирует Room, а не твоё приложение. Тестируй запросы с реальной логикой: суммы, фильтры, границы диапазонов дат, порядок сортировки. - Граничные строки — вот где прячутся баги.
WHERE nextChargeDate <= :today— пиши тест с подпиской, срок которой наступает ровно сегодня, а не только с явно прошедшими или явно будущими случаями. - У миграций есть собственный тест, загружающий каждую историческую схему из экспортированного JSON и прогоняющий
MigrationTestHelperвперёд. Это тема для отдельной статьи — настройку exportSchema, от которой это зависит, смотри в статье о local-first Android.
Уровень 2: Flow-тесты репозитория с Turbine
Слой репозитория обычно объединяет два или три Flow — например, вывод DAO плюс настройку из DataStore — и эта логика объединения — как раз то, что очевидно в момент написания и оказывается неверным полгода спустя после рефакторинга. Turbine делает проверки на эмиссиях Flow читаемыми вместо путаницы из коллекторов в 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-тестирования является малоценной работой против интерфейса, который будет переделан раньше, чем тест успеет окупиться. Я оставляю это для потоков, где тихая регрессия по-настоящему дорого обходится:
- Путь действия уведомления (отметить принятым / отложить / пропустить) — потому что сломанное нажатие здесь означает пропущенную дозу, а не косметический баг.
- Шаг запроса разрешения в онбординге — потому что регрессия здесь невидима при ручном тестировании (ты, разработчик, уже выдал все разрешения месяцы назад) и фатальна для первых пяти минут нового пользователя.
@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-тестов для приложения, которое поддерживает один человек: горстка тестов на путях, где «тихо перестало работать» — худший из возможных исходов, а не набор, пытающийся покрыть каждый пиксель.
Что я сознательно не тестирую
Пропуск этого — не лень, а место, где отдача от потраченного времени реально стремится к нулю:
- Геттеры/сеттеры и data-классы без логики. Ломаться нечему.
- Compose-превью и чистый layout. Инструмент сравнения скриншотов ловит это гораздо лучше теста на основе assert, а ROI от добавления такого инструмента на этом масштабе я пока не нашёл.
- Внутренности сторонних библиотек. Доверяй выполнению запросов Room; тестируй свой SQL, а не SQLite.
Что это даёт
Ничто из этого не составляет большой набор тестов — DAO-тесты для запросов с реальной логикой, десяток тестов Turbine для Flow, объединяющих состояние, и горстка Compose-тестов для путей, где молчание обходится дорого. То, что это покупает, вполне конкретно: каждый раз, когда я трогаю запрос или рефакторю репозиторий, сбой проявляется как красный тест за несколько секунд, а не как письмо в поддержку три недели спустя от кого-то, у кого сумма подписок была неверна ровно на величину одной строки в евро.
Для конторы из одного человека это и есть настоящий смысл тестирования — не доказать, что код правилен в каком-то абстрактном смысле, а сделать так, чтобы когда что-то сломается, ты узнал об этом первым.
// По теме
Ещё из журнала
Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему
Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.
R8 и ProGuard для Kotlin, Room и Compose: сбой, который случается только в релизе
Почему приложение Android на Room + Compose идеально работает в debug и падает в продакшене, и какие именно правила keep R8/ProGuard ловят это раньше пользователя.
In-App Review API в Android: как просить оценку, не раздражая пользователя
Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.