本文へスキップ
すべての記事

2026年、ローカルファーストなAndroidアプリをテストする — 実際のバグを見つけるRoom、Flow、Compose UIテスト

2026年版、ローカルファーストAndroidアプリの実践的なテスト戦略 — インメモリRoomによるDAOテスト、FlowのためのTurbine、そして本当に書く価値のある数少ないCompose UIテスト。

MFKAPPS 1 分で読めます

サーバーがなければ、バグが本番に出る前に気づいてくれるサーバー側のチームも存在しない。ローカルファーストなAndroidアプリでのすべてのミスは、それを最初に発見する運用ダッシュボードもないまま、ユーザーの端末上のデータベースに直接着地する。これが、APIのレスポンスを表示するだけの薄いクライアントをテストするよりも、ローカルファーストなアプリのテストがより慎重さに値する理由として、私が何度も立ち返る論拠だ——データベース層こそが製品そのものであり、その前に立つ唯一の防衛線は私自身だ。

これは、GranynSublyなどで実際に運用しているテスト構成だ——3つの層、どれもエキゾチックではなく、どれも省略する言い訳が立たないほど安上がりだ。

実際にテストしている3つの層

すべてが同じ厳密さに値するわけではない。その層でのバグのコストに応じてテストを分けている。

  1. DAOテスト — すべてのクエリを、実際のインメモリRoomデータベースに対して実行する。お金が誤って計算されたり、フィルターが行を静かに落としたりするのはここだ。
  2. リポジトリ/Flowテスト — DAOとUIの間に座るマッピングとコンビネーションのロジック。「DBでは正しいのに画面には古いデータが表示される」バグはここに住んでいる。
  3. 少数の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が正しく見えたためコードレビューは通過した。実際の数値に対してアサーションするテストを実行した瞬間に失敗した。

この層を価値あるものにし続けるいくつかのルール:

  • フレームワークではなくクエリをテストする。 insert()の後にgetById()が同じ行を返すことを確認するテストは書かない——それはRoomをテストしているのであって、自分のアプリをテストしているわけではない。合計、フィルター、日付範囲の境界、ソート順など、実際のロジックを持つクエリをテストする。
  • 境界行こそバグが隠れる場所だ。 WHERE nextChargeDate <= :today — テストは、明らかに過去や明らかに未来のケースだけでなく、ちょうど今日が期限のサブスクリプションで書く。
  • マイグレーションには専用のテストがある。エクスポートされたJSONから各過去のスキーマを読み込み、MigrationTestHelperを前方向に実行する。これは別の記事に値する内容だ——これが依存するexportSchemaの設定についてはローカルファーストAndroidの記事を参照してほしい。

層2: TurbineによるFlowベースのリポジトリテスト

リポジトリ層は通常、2つか3つのFlow——例えばDAOの出力とDataStoreの設定など——を組み合わせており、その組み合わせロジックはまさに、書いているときには明白でも、6ヶ月後のリファクタリングの後には間違っているような類のものだ。Turbineは、runBlockingによるコレクターの混乱ではなく、Flowのエミッションに対するアサーションを読みやすくしてくれる。

@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はフェイクされている、それはすでにカバーされているからだ——UIが実際に見ることになる状態のシーケンスをテストすることにある。Flowのバグが「間違ったデータ」として現れることは稀で、「正しいデータ、しかし1回分遅れて」という形で現れることが多く、これはコードを読むよりもテストでエミッションが流れるのを観察する方がはるかに検出しやすい。

層3: 書く価値のある数少ないCompose UIテスト

すべての画面をUIテストするわけではない——小規模なアプリでのCompose UIテストの大半は、テストが元を取る前に再設計されてしまうUIに対する低価値な作業だ。サイレントなリグレッションが本当にコストの高いフローに限定している。

  • 通知アクションのパス(飲んだ/スヌーズ/スキップ) — ここでの壊れたタップは、見た目の不具合ではなく服薬の見逃しを意味するからだ。
  • オンボーディングの権限リクエストのステップ — ここでのリグレッションは手動テストでは見えない(開発者である自分はすでに数ヶ月前にすべての権限を許可している)し、新規ユーザーの最初の5分間にとって致命的だからだ。
@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テストの予算のすべてだ。「静かに動作しなくなる」ことが最悪の結果であるパスに絞った数個のテストであり、すべてのピクセルをカバーしようとするスイートではない。

意図的にテストしていないもの

これらを省略するのは怠惰ではなく、時間に対するリターンが実際にゼロになる場所だからだ。

  • ロジックのないgetter/setterやdataクラス。 壊れるものが何もない。
  • Composeのプレビューと純粋なレイアウト。 スクリーンショット差分ツールの方がアサーションベースのテストよりもこれをうまく検出できる。このスケールでは、まだ追加するだけのROIを見出せていない。
  • サードパーティライブラリの内部実装。 Roomのクエリ実行は信頼する。SQLiteではなく、自分のSQLをテストする。

見返り

これらはどれも大きなテストスイートではない——実際のロジックを持つクエリに対するDAOテスト、状態を組み合わせるFlowに対する十数個のTurbineテスト、そして沈黙がコストの高いパスに対する数個のComposeテスト。これが買ってくれるものは具体的だ。クエリに触れたりリポジトリをリファクタリングしたりするたびに、失敗は数秒で赤いテストとして現れる——3週間後に、サブスクリプションの合計がユーロ建ての行1つ分だけ正確に間違っていた誰かからのサポートメールとしてではなく。

一人でやっているショップにとって、それがテストの本当の目的だ——コードが抽象的な意味で正しいことを証明するのではなく、何かが壊れたときに、それに最初に気づくのが自分自身であることを確実にすることだ。