mcpField
A
applying-testing-strategies

applying-testing-strategies

github.com/skydovesテスト・品質33964 回インストール22 週間前に更新
リポジトリ

applying-testing-strategies について

Android チーム向けに、決定的で構造化されたテストの方針と、Hilt による依存関係の置き換えを扱うスキルです。文書化された指針と慣習を区別し、複数テストで Fake を使う方法を示します。

主な機能

  1. 01テストの非決定性の主な要因
  2. 02テスト指針の参照元の整理
  3. 03Given-When-Then と命名規則の説明
  4. 04Hilt テストルールと実行順の解説
  5. 05Hilt のバインディング置換方法の比較

活用シーン

  1. 01新しいテストモジュールの規約策定
  2. 02CI で断続的に失敗するテストの調査
  3. 03UI テストで本番依存を Fake に置き換える

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/android-testing-skills --skill applying-testing-strategies

インストール後、Claude Code で/applying-testing-strategiesを入力して読み込まれたか確認します。

詳細

テスト戦略の適用 — 決定性、構造、Hilt

テストスイートを拡張するには、3つの要素が必要です。決定性(同じ入力なら同じ結果になる)、構造化(誰が読んでも Given/When/Then を見つけられる)、差し替え可能(バインディングを1つ切り替えるだけで、本番環境の依存関係グラフをフェイクに差し替えられる)です。このスキルでは、Google の戦略に関するガイダンスを、それぞれのルールが実際にどのページに記載されているかを明示しながらまとめ、ビルドに組み込むための Hilt の仕組みも説明します。

このスキルを使う場面

  • ユーザーが「Android テストはどう構成すればよいか」「Given-When-Then と Arrange-Act-Assert のどちらがよいか」「テストメソッドの名前はどう付ければよいか」と尋ねた場合。
  • ユーザーが @HiltAndroidTest、HiltAndroidRule、@TestInstallIn、@UninstallModules、@BindValue、またはルールの順序について尋ねた場合。
  • ユーザーが「ローカルではテストが通るのに、CI では失敗する」と報告した場合。通常、決定性またはハーメチック性の問題です。
  • ユーザーが新しいモジュールを作成していて、戦略ドキュメントや規約の一覧を必要としている場合。
  • ユーザーが RuleChain.outerRule を使って HiltAndroidRule と ActivityScenarioRule または composeTestRule を組み合わせる方法に言及した場合。

このスキルを使わない場面

  • テストの規模(小・中・大)を選ぶ場合 — ../../concepts/understanding-the-testing-pyramid/SKILL.md を使います。
  • どの動作をカバーするかを選ぶ場合 — ../../concepts/choosing-what-to-test/SKILL.md を使います。
  • フェイクとモックのどちらを使うかを選ぶ場合 — ../../doubles/picking-test-doubles/SKILL.md を使います。
  • ソースセットを構成する場合 — ../organizing-test-source-sets/SKILL.md を使います。
  • 単一の不安定な Compose アニメーションテストをデバッグする場合 — ../../../compose/synchronization/synchronizing-with-idle/SKILL.md を使います。

前提条件

  • ../../concepts/understanding-the-testing-pyramid/SKILL.md にある、小・中・大のピラミッドの考え方を理解していること。
  • 依存関係を注入するテスト対象があること(Hilt、手動のコンストラクターインジェクション、または別の DI ライブラリ)。
  • Hilt のセクションでは、dagger.hilt.android.testing.HiltAndroidRule と dagger.hilt.android.testing.HiltAndroidTest が androidTestImplementation で利用できること。
  • 決定性については、kotlinx-coroutines-test、Robolectric(ホストテストを行う場合)、および System.currentTimeMillis()/Clock.systemDefaultZone() をスタブ化できること。

情報源の対応表(どのページに何が記載されているか)

tasks/research/R8-android-fundamentals.md によると、戦略に関する概念は3つの異なるページに記載されています。PR のレビュアーがスキルを引用するため、情報源を明示することが重要です。

概念実際に記載されているページ
定性的なピラミッド(「小規模なテストを多く、大規模なテストを少なく」)/training/testing/fundamentals/strategies
Unit/Component/Feature/Application/RC の5段階の表/training/testing/fundamentals/strategies
各レイヤーのネットワークアクセス/training/testing/fundamentals/strategies
決定性/不安定なテストへの対処/training/testing/instrumented-tests/stability(/strategies ではありません)
ハーメチックテストの定義/training/testing/fundamentals/test-doubles(簡単な言及のみ)
Given-When-Then のコメントスタイルどのページにも明記されていません。コードサンプルで使われているだけです
Arrange-Act-Assertどのページにも明記されていません
テストの命名/training/testing/instrumented-tests にあるバッククォートについての注意書きのみ
@HiltAndroidTest、HiltAndroidRule、@TestInstallIn、@UninstallModules、@BindValue/training/dependency-injection/hilt-testing

決定性に関するガイダンスを /strategies のものとして説明してはいけません。記載されているのは /instrumented-tests/stability です。Google が「Given-When-Then を推奨している」と主張してはいけません。各ページにそのような記述はなく、サンプルでコメントスタイルを使っているだけです。

決定性

/training/testing/instrumented-tests/stability(および CORPUS §C「よくある落とし穴」)によると、主に次の 4 つの要因が非決定性を引き起こします。

  1. 時間 — System.currentTimeMillis()、Clock.systemDefaultZone()、Instant.now()。Clock を注入するか、kotlinx-coroutines-test の仮想時間を使用します。
  2. 並行処理/コルーチン — Dispatchers.IO の直接使用、runBlocking、実際の delay。runTest を使い、CoroutineDispatcher を注入し、Dispatchers.setMain(dispatcher) で Dispatchers.Main を置き換えます。../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md を参照してください。
  3. アニメーション/フレームタイミング — Compose の MainTestClock、View のアニメーション。Gradle の testOptions.animationsDisabled = true または ADB の Settings.Global.WINDOW_ANIMATION_SCALE = 0 で無効化します。Compose では、docs/SPEC.md §5 の skydoves ディレクティブに従い、mainClock.autoAdvance = false を設定します。
  4. 外部状態 — ネットワーク、実際の DB、Random()、UUID.randomUUID()。シードを注入し、フェイクを使い、インストルメンテーションテストの前に pm clear を実行します。

具体的なチェックリスト:

  • Clock(または同等の時間ソース)が注入されており、テストでは Clock.fixed(Instant.parse(...), ZoneId.of("UTC")) に置き換えます。
  • DispatcherProvider インターフェースを介して Dispatchers を注入するか、@Before で Dispatchers.setMain(StandardTestDispatcher()) に置き換えます。
  • Random にシードを設定する(Random(42))か、テストで置き換える RandomProvider の背後にラップします。
  • build.gradle.kts で testOptions.animationsDisabled = true を設定します。
  • CI では各インストルメンテーションテストの前に pm clear <pkg> を実行します(Test Orchestrator が自動的に実行します。そうでない場合は @Before を追加します)。

ハーメチックテストと統合テスト

/strategies ページでは、「ハーメチック」を最上位の用語として定義していません。最も近い定義は /test-doubles にあります。

「ハーメチックテストでは、インターネットからのデータ取得など、すべての外部依存関係を排除します。」 — developer.android.com/training/testing/fundamentals/test-doubles

運用上のルールは /strategies にあります。

レイヤーネットワークアクセス
ユニットなし
コンポーネントなし
機能「モックされたネットワークアクセスをサポート」
アプリケーション該当なし
リリース候補該当なし

「ハーメチック」が /strategies の独立したセクションであるかのように提示してはなりません — この用語は /test-doubles でのみ、付随的に言及されています。ハーメチック性について説明するときは、両方のページを必ず引用してください。用語については /test-doubles、運用上のルールについては /strategies を引用します。

Given-When-Then/Arrange-Act-Assert

4 つの主要ページ(/fundamentals、/what-to-test、/test-doubles、/strategies)のいずれにも、どちらのパターンも名前が明記されていません。一方、Google のコードサンプルでは、これらが実際に使用されています。/fundamentals より:

// Given an instance of MyViewModel
val viewModel = MyViewModel(myFakeDataRepository)

// When data is loaded
viewModel.loadData()

// Then it should be exposing data
assertTrue(viewModel.data != null)

— developer.android.com/training/testing/fundamentals(ローカル/ホスト側のユニットテストの例)

同じページより:

// When the Continue button is clicked
onView(withText("Continue")).perform(click())
// Then the Welcome screen is displayed
onView(withText("Welcome")).check(matches(isDisplayed()))

— developer.android.com/training/testing/fundamentals(Espresso の例)

「Google の公式サンプルでは Given-When-Then のコメントブロックが一貫して使われています」と引用するのは正確です。「Google は Given-When-Then を推奨しています」と引用してはなりません — これは事実ではありません。

Given-When-Then と Arrange-Act-Assert は、語彙が異なるだけで同じ構造です。

Given (Arrange) — set up the SUT and dependencies
When  (Act)     — invoke the behavior under test
Then  (Assert)  — assert on the resulting state or interaction

リポジトリごとに、どちらか一方の語彙を選びます(skydoves では、Google のサンプルに合わせて Given-When-Then を推奨)。メソッド名ではなく、インラインコメントブロックとして使用します。

テストの命名

4 つの主要ページ全体で、逐語的に記載された命名ガイダンスは /instrumented-tests にあるバッククォートについての注意書きだけです。

「注: Kotlin でバッククォートを使ったテストの命名は、API 30 以降を実行するデバイスでのみサポートされています。」 — developer.android.com/training/testing/instrumented-tests

これによると:

  • ローカルテスト(src/test/)— JVM 上で実行され、Android ランタイムでは実行されないため、バッククォートを使った名前でも問題ありません。
  • インストルメンテーションテスト(src/androidTest/)— バッククォートを使った名前には、テスト APK の minSdk >= 30 が必要です。それより低い場合は、camelCase または snake_case_with_underscores を使用します。

skydoves の規約(これら 2 つの事実を組み合わせたもの):文として読める methodName_state_expected 形式です。

loadUsers_emptyList_emitsEmptyState
applyCoupon_negativeTotal_emitsError
save_orchestratorMarksDirtyOnce

テストでの Hilt — 仕組みの全容

出典:developer.android.com/training/dependency-injection/hilt-testing および CORPUS §F.6。

テストに Hilt を使う理由

「コンストラクターインジェクションを使用するクラスをテストする場合、そのクラスのインスタンス化に Hilt を使う必要はないため、ユニットテストに Hilt は必要ありません。」 — developer.android.com/training/dependency-injection/hilt-testing

「インテグレーションテストでは、Hilt は本番コードと同様に依存関係をインジェクションします。Hilt はテストごとに新しいコンポーネント一式を自動的に生成するため、Hilt を使ったテストではメンテナンスが不要です。」 — developer.android.com/training/dependency-injection/hilt-testing

つまり、ユニットテストではコンストラクターインジェクションを使い(Hilt は使わず)、UI テスト/インテグレーションテストでは Hilt を使います。

@HiltAndroidTest と最初に実行する HiltAndroidRule
@HiltAndroidTest
class HomeScreenTest {
    @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeRule = createAndroidComposeRule<MainActivity>()

    @Inject lateinit var userRepository: UserRepository

    @Before fun inject() = hiltRule.inject()
}

HiltAndroidRule は、インジェクション対象のグラフに触れるルール(Activity の起動、Compose のセットアップなど)よりも先に実行する必要があります。/hilt-testing のページでは、これを「HiltAndroidRule が最初に実行される」と表現しています。JUnit4 では値の小さい order が先に実行されるため(@Rule(order = 0) は order = 1 より優先)、通常はここに示すように 0、次に 1 とします。ただし、ルールは「Hilt が先」であり、「order = 0」に限られるわけではありません。負の数(order = -1 と order = 0 の組み合わせ)も使用でき、単調増加する値であればどの方式でも問題ありません。/hilt-testing より:

「Hilt を使う UI テストには、すべて @HiltAndroidTest を付ける必要があります。このアノテーションは、テストごとに Hilt コンポーネントを生成する役割を担います。また、テストクラスに HiltAndroidRule を追加する必要があります。」 — developer.android.com/training/dependency-injection/hilt-testing

「テストに型をインジェクションするには、フィールドインジェクションに @Inject を使用します。Hilt に @Inject フィールドを設定させるには、hiltRule.inject() を呼び出します。」 — developer.android.com/training/dependency-injection/hilt-testing

@TestInstallIn — すべてのテストでバインディングを置き換える
@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [AnalyticsModule::class],
)
abstract class FakeAnalyticsModule {
    @Singleton @Binds
    abstract fun bindAnalyticsService(impl: FakeAnalyticsService): AnalyticsService
}

置き換えは、モジュール内のすべての @HiltAndroidTest に適用されます。同じフェイクをすべてのテストで使える場合(インメモリ DB、何もしないアナリティクスなど)に使用します。

@UninstallModules — 1 つのテストだけで置き換える
@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {
    @Module @InstallIn(SingletonComponent::class)
    abstract class TestModule {
        @Singleton @Binds
        abstract fun bindAnalyticsService(impl: FakeAnalyticsService): AnalyticsService
    }
}

/hilt-testing の警告より(スキルで必ず明示すること):

「警告: @InstallIn が付いていないモジュールはアンインストールできません。これを試みるとコンパイルエラーになります。」

「警告: @UninstallModules でアンインストールできるのは @InstallIn モジュールのみであり、@TestInstallIn モジュールは対象外です。」 — developer.android.com/training/dependency-injection/hilt-testing

@BindValue — 1 つのテストで手軽に差し替える
@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {
    @BindValue @JvmField
    val analyticsService: AnalyticsService = FakeAnalyticsService()
}

@BindValue は軽量な方法で、テストモジュールクラスは不要です。テストで単一のインスタンスを設定し、それを検証する場合に使用します。

@CustomTestApplication — Hilt を継承していない基底クラス

本番の Application が Hilt を継承していない基底クラス(MultiDexApplication、ベンダー製アプリクラスなど)を継承している場合、Hilt はテストアプリを自動生成できません。次のようにします。

@CustomTestApplication(BaseApplication::class)
interface HiltTestApplication

生成された HiltTestApplication_Application は、testInstrumentationRunnerArguments に指定するか、カスタムの AndroidJUnitRunner を介して指定します。

複数のルールを使う場合の順序
@HiltAndroidTest
class SettingsActivityTest {
    @get:Rule(order = 0) var hiltRule = HiltAndroidRule(this)
    @get:Rule(order = 1) var settingsActivityTestRule = SettingsActivityTestRule(...)
}

— developer.android.com/training/dependency-injection/hilt-testing

中間のルールを組み合わせるには、RuleChain を使うこともできます。

@get:Rule
val chain: TestRule = RuleChain
    .outerRule(HiltAndroidRule(this))
    .around(InstantTaskExecutorRule())
    .around(MainDispatcherRule())

パターン

パターン:誤りと正しい例 — Given-When-Then のコメントブロック
// WRONG — no structure; reader cannot find the act vs assert boundary
@Test
fun loadsUser() = runTest {
    val repo = FakeUserRepository().apply { seed(User(UserId("u1"), "Alice")) }
    val vm = UserViewModel(repo)
    vm.load(UserId("u1"))
    val state = vm.state.value
    assertEquals("Alice", state.name)
    assertFalse(state.isLoading)
}
// WRONG because: the test crams setup, action, and assertion together. With three or
// more lines per stage, readers cannot locate failures fast. Google's own samples use
// Given-When-Then comments as visual separators.
// RIGHT
@Test
fun loadUser_seededId_emitsLoadedState() = runTest {
    // Given a fake repo seeded with Alice
    val repo = FakeUserRepository().apply { seed(User(UserId("u1"), "Alice")) }
    val vm = UserViewModel(repo)

    // When loading the seeded id
    vm.load(UserId("u1"))

    // Then the state is Loaded with Alice
    assertEquals(UserUiState.Loaded(name = "Alice"), vm.state.value)
}
パターン:誤りと正しい例 — Hilt ルールの順序
// WRONG
@HiltAndroidTest
class HomeScreenTest {
    @get:Rule val composeRule = createAndroidComposeRule<MainActivity>()  // order = 0 by default
    @get:Rule val hiltRule = HiltAndroidRule(this)
}
// WRONG because: with no order, JUnit picks ordering implementation-defined. The
// Activity may launch before Hilt's component is built, throwing at @Inject sites.
// Per /hilt-testing, HiltAndroidRule must run first.
// RIGHT
@HiltAndroidTest
class HomeScreenTest {
    @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
    @get:Rule(order = 1) val composeRule = createAndroidComposeRule<MainActivity>()
}
パターン:誤りと正しい例 — 非決定的な時間
// WRONG
class DueDateTest {
    @Test fun reportsOverdue() {
        val task = Task(dueAt = Instant.now().minusSeconds(60))
        assertTrue(task.isOverdue())   // depends on real wall clock; flake risk on slow CI
    }
}
// RIGHT
class DueDateTest {
    private val fixedNow = Instant.parse("2024-01-01T00:00:00Z")
    private val clock = Clock.fixed(fixedNow, ZoneId.of("UTC"))

    @Test fun reportsOverdue() {
        val task = Task(dueAt = fixedNow.minusSeconds(60), clock = clock)
        assertTrue(task.isOverdue())
    }
}
パターン:誤りと正しい例 — every { } より @BindValue
// WRONG — mocking a Hilt-injected real instance
@HiltAndroidTest
class SettingsActivityTest {
    @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
    @Inject lateinit var analytics: AnalyticsService

    @Test fun tracks() {
        // analytics is the real production instance; tests now mutate prod state
        every { (analytics as RealAnalyticsService).enabled = false }   // does not compile
    }
}
// RIGHT — replace at the binding boundary
@UninstallModules(AnalyticsModule::class)
@HiltAndroidTest
class SettingsActivityTest {
    @BindValue @JvmField
    val analytics: AnalyticsService = FakeAnalyticsService()

    @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
}

必須ルール

  • HiltAndroidRule を、注入されたグラフにアクセスする他のルール(Activity、Compose、Fragment、Coroutine)と組み合わせる場合は、必ず最初に実行されるようにします。一般的な形は、Hilt に @Rule(order = 0)、次のルールに @Rule(order = 1) を指定する方法ですが、Hilt が最初になる単調な順序であれば、どのような指定でもかまいません。/hilt-testing が定めているのは「HiltAndroidRule が最初に実行される」ことであり、order = 0 であることまでは指定していません。
  • @Inject フィールドを持つテストクラスでは、@Before で hiltRule.inject() を必ず呼び出します。
  • @TestInstallIn で置き換えるモジュールに @UninstallModules を付けてはいけません。ページの警告ボックスにあるとおり、Hilt のコンパイルが失敗します。
  • 決定性に関するガイダンスを /training/testing/fundamentals/strategies に帰属させてはいけません。/training/testing/instrumented-tests/stability を使用してください。
  • 「Google が Given-When-Then を推奨している」と主張してはいけません。ページではサンプルに使用していますが、推奨しているわけではありません。
  • 出力が依存するコードでは、Clock(または同等の時刻ソース)、DispatcherProvider、および Random のシードを必ず注入します。
  • ローカルテストの名前には、自由にバッククォートを使用できます。API 30 未満のデバイスで実行される可能性があるインストルメンテーションテストでは、camelCase_state_expected を使用してください(/instrumented-tests にあるとおり、「Kotlin でテスト名にバッククォートを使う機能は、API 30 以降を実行するデバイスでのみサポートされています」。判定基準はモジュールの minSdk だけではなく、実行時のデバイス API です)。
  • 推奨: Google のコードサンプルで使われている用語に合わせ、Arrange-Act-Assert より Given-When-Then を使用します。
  • 推奨: 単一テストでバインディングを差し替える場合は @BindValue、モジュール全体で差し替える場合は @TestInstallIn を使用します。
  • 推奨: テストごとに ADB シェルを呼び出すのではなく、testOptions.animationsDisabled = true でアニメーションを無効にします。

検証

  • すべての @HiltAndroidTest クラスで HiltAndroidRule が宣言され、クラス内の他の JUnit ルールより先に実行される order が指定されている(一般的には @Rule(order = 0))。
  • @Inject フィールドを持つすべての @HiltAndroidTest クラスで、@Before 内から hiltRule.inject() が呼び出されている。
  • 本番コードで Fake* クラスが参照されていない。すべてのフェイクは src/androidTest/、src/test/、または専用の core-testing モジュールに配置されている。
  • シードまたは注入なしに、テストメソッドが Instant.now()、System.currentTimeMillis()、または Random() を直接読み取っていない。
  • モジュールの build.gradle.kts で testOptions.animationsDisabled = true が設定されている。
  • CI での実行結果がローカルでの実行結果と同じになる。ネットワーク、実時間、または周囲のデバイス状態に依存するテストがない。
  • モジュール内のすべてのテストが、同じ構造規約(Given-When-Then または Arrange-Act-Assert のいずれか。両方は使用しない)に従っている。

参考資料

  • developer.android.com/training/testing/fundamentals/strategies — 定性的なピラミッド、レイヤーごとのネットワークアクセス表。
  • developer.android.com/training/testing/fundamentals/test-doubles — 隔離テストの定義(簡単な言及)。
  • developer.android.com/training/testing/instrumented-tests/stability — 決定性/不安定なテストに関するガイダンス(/strategies には記載されていません)。
  • developer.android.com/training/testing/instrumented-tests — バッククォートを使った命名に関する注意(「バッククォートの使用は API 30 以降を搭載したデバイスでのみサポートされています」)。
  • developer.android.com/training/dependency-injection/hilt-testing — @HiltAndroidTest、HiltAndroidRule、@TestInstallIn、@UninstallModules、@BindValue、@CustomTestApplication、警告ボックス、ルールの順序に関する例。
  • tasks/research/R8-android-fundamentals.md — Hilt に関する引用の原文と、ページごとの情報源マップ。
  • CORPUS §G.3 — testutils/testutils-ktx/src/jvmMain/kotlin/androidx/testutils/MainDispatcherRule.jvm.kt にある、JUnit4 用の MainDispatcherRule ラッパー。
  • 関連するスキル:../../concepts/understanding-the-testing-pyramid/SKILL.md、../../concepts/choosing-what-to-test/SKILL.md、../../doubles/picking-test-doubles/SKILL.md、../organizing-test-source-sets/SKILL.md。
  • カテゴリー横断:../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.md、../../../jvm-tests/coroutines/testing-flows-with-turbine/SKILL.md、../../../jvm-tests/runner/configuring-junit4-on-android/SKILL.md、../../../jvm-tests/robolectric/using-robolectric-correctly/SKILL.md、../../../instrumentation/runner/running-instrumented-tests-with-androidjunit4/SKILL.md、../../../instrumentation/scenarios/launching-activities-with-activityscenario/SKILL.md、../../../adb/observability/extracting-logs-with-logcat/SKILL.md。

コメント