applying-testing-strategies について
Android チーム向けに、決定的で構造化されたテストの方針と、Hilt による依存関係の置き換えを扱うスキルです。文書化された指針と慣習を区別し、複数テストで Fake を使う方法を示します。
主な機能
- 01テストの非決定性の主な要因
- 02テスト指針の参照元の整理
- 03Given-When-Then と命名規則の説明
- 04Hilt テストルールと実行順の解説
- 05Hilt のバインディング置換方法の比較
活用シーン
- 01新しいテストモジュールの規約策定
- 02CI で断続的に失敗するテストの調査
- 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 つの要因が非決定性を引き起こします。
- 時間 —
System.currentTimeMillis()、Clock.systemDefaultZone()、Instant.now()。Clockを注入するか、kotlinx-coroutines-testの仮想時間を使用します。 - 並行処理/コルーチン —
Dispatchers.IOの直接使用、runBlocking、実際のdelay。runTestを使い、CoroutineDispatcherを注入し、Dispatchers.setMain(dispatcher)でDispatchers.Mainを置き換えます。../../../jvm-tests/coroutines/testing-coroutines-with-runtest/SKILL.mdを参照してください。 - アニメーション/フレームタイミング — Compose の
MainTestClock、View のアニメーション。Gradle のtestOptions.animationsDisabled = trueまたは ADB のSettings.Global.WINDOW_ANIMATION_SCALE = 0で無効化します。Compose では、docs/SPEC.md§5 の skydoves ディレクティブに従い、mainClock.autoAdvance = falseを設定します。 - 外部状態 — ネットワーク、実際の 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。