launching-fragments-with-fragmentscenario について
FragmentScenario で Android の Fragment を単独起動するテストスキルです。コンテナの有無、テーマやファクトリーの設定、ライフサイクル制御、ナビゲーションの制約を扱います。
主な機能
- 01コンテナ内・なしでの起動
- 02引数とカスタムテーマの指定
- 03FragmentFactory の注入
- 04ライフサイクルの制御と確認
- 05ナビゲーションの制約の把握
活用シーン
- 01Fragment のライフサイクルとビューの単体テスト
- 02AppCompat UI 用テストテーマの設定
- 03コンストラクター引数を持つ Fragment のテスト
- 04テスト中のナビゲーションコントローラーエラーへの対処
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill launching-fragments-with-fragmentscenarioインストール後、Claude Code で/launching-fragments-with-fragmentscenarioを入力して読み込まれたか確認します。
詳細
FragmentScenario でのフラグメントの起動 — フラグメントを単独でテストする
FragmentScenario は、単一のフラグメントを内部の EmptyFragmentActivity 内でホストするため、実際の画面を使わずにフラグメントの onCreate / onViewCreated /ライフサイクルをテストできます。起動方法は 2 つあります。launchFragmentInContainer<F>() はフラグメントを android.R.id.content に追加し(ビューを含むライフサイクル全体)、launchFragment<F>() はコンテナなしでアタッチします(ヘッドレス)。デフォルトのテーマは android:Theme.WithActionBar を継承しており、AppCompat ではありません。AppCompat ウィジェットを使用する場合は、themeResId による上書きが必要です。このスキルでは、2 つのアーティファクト、テーマに関する注意点、ナビゲーションの制約について説明します。
このスキルを使用する場合
- ホストの Activity 全体を起動せずに、単一の Fragment をテストしたい場合。
- テスト中に
IllegalArgumentException: You need to use a Theme.AppCompat theme (or descendant) with the design libraryが発生し、そのフラグメントで AppCompat / Material ウィジェットを使用している場合。 - テストで
findNavController()を呼び出した際に、Fragment ... does not have a NavController setが発生する場合。 - 状態変化のテストのため、フラグメントのライフサイクル(
STARTED/RESUMED/DESTROYED)を制御したい場合。 - 引数なしコンストラクターがないフラグメントに、カスタムの
FragmentFactoryを注入したい場合。 - 「ヘッドレスフラグメント」に言及している場合や、
onViewCreatedが呼び出されない理由を尋ねている場合。
このスキルを使用しない場合
- ホストが Fragment ではなく Activity の場合 —
../launching-activities-with-activityscenario/SKILL.mdを参照してください。 - フラグメントに実際のナビゲーションスタック(
NavHostFragment)が必要な場合 —FragmentScenarioはナビゲーションスタックを提供しません。代わりに、NavHostFragmentを設定するカスタムのテストホストアクティビティか、TestNavHostControllerを使用してください。 - ランナー / 依存関係のスタックがまだ設定されていない場合 —
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.mdから始めてください。 - フラグメントが Compose コンテンツをホストし、テストで Compose ツリーを介して操作する場合 —
../../../compose/setup/configuring-test-dependencies/SKILL.mdを参照してください。
前提条件
androidTestImplementation("androidx.fragment:fragment-testing:1.8.x")— API:FragmentScenario、launchFragmentInContainer、launchFragment、withFragment。debugImplementation("androidx.fragment:fragment-testing-manifest:1.8.x")—EmptyFragmentActivityを宣言するマニフェストエントリーです。Android M 以降では、マニフェストマージャーがEmptyFragmentActivityを取り込めるよう、このアーティファクトをdebugImplementation(ホストテストの場合はtestImplementation)に追加する必要があります。../../runner/running-instrumented-tests-with-androidjunit4/SKILL.mdに記載されているランナースタック。
ワークフロー
- 1. 両方の成果物を適切な設定で追加します。 Android M 以降では、2 つの成果物に分ける必要があります。
dependencies {
androidTestImplementation("androidx.fragment:fragment-testing:1.8.5")
debugImplementation("androidx.fragment:fragment-testing-manifest:1.8.5")
}
fragment-testing には API が含まれ、fragment-testing-manifest には <activity android:name="androidx.fragment.app.testing.EmptyFragmentActivity"> のマニフェストエントリとデフォルトテーマのみが含まれます。マニフェスト成果物がないと、launchFragmentInContainer / launchFragment は ActivityNotFoundException: EmptyFragmentActivity でクラッシュします。tasks/research/R2-scenario.md の 568~619 行を参照してください。
- 2. ビューの要件に応じて起動方法を選びます。
import androidx.fragment.app.testing.launchFragment
import androidx.fragment.app.testing.launchFragmentInContainer
// In-container — adds at android.R.id.content; full lifecycle including onCreateView/onViewCreated
val s1: FragmentScenario<MyFragment> = launchFragmentInContainer<MyFragment>()
// Headless — containerViewId = 0; onCreateView still runs but the view is not attached to a window
val s2: FragmentScenario<MyHeadlessFragment> = launchFragment<MyHeadlessFragment>()
launchFragmentInContainer は containerViewId = android.R.id.content を使用します(isViewAttachedToWindow == true)。launchFragment は containerViewId = 0 を使用します(ヘッドレス。isViewAttachedToWindow == false)。ソース:R2 の 263~389 行、683~684 行。
- 3.
requireArguments()を読み取るフラグメントにはfragmentArgsを渡します。
val args = bundleOf("user_id" to 42, "from_deep_link" to true)
val scenario = launchFragmentInContainer<UserDetailFragment>(fragmentArgs = args)
- 4. AppCompat / Material のフラグメントでは、
themeResIdを AppCompat テーマで上書きします。 デフォルトテーマのFragmentScenarioEmptyFragmentActivityThemeはTheme.AppCompatではなく、プラットフォームの Holo テーマであるandroid:Theme.WithActionBarを継承しています。AppCompat ウィジェットは、ビューのインフレーション時にYou need to use a Theme.AppCompat theme (or descendant) with the design libraryというエラーでクラッシュします。R2 の 466~501 行を参照してください。
val scenario = launchFragmentInContainer<MyAppCompatFragment>(
themeResId = R.style.Theme_MyApp, // any AppCompat / Material theme
)
EmptyFragmentActivity.setTheme は super.onCreate より前に呼び出されるため、フラグメントのビューのインフレーションが始まる前に上書きが適用されます(R2 の 490~501 行)。
- 5. デフォルトコンストラクターを持たないフラグメントには
FragmentFactoryを注入します。 どちらの起動方法もfactory: FragmentFactory?パラメーターを受け取ります(R2 の 263~323 行)。ファクトリーはEmptyFragmentActivityのViewModelStoreをキーとするFragmentFactoryHolderViewModelに保存されるため、recreate()後も保持されます。
class MyFragment(private val repo: UserRepository) : Fragment() { /* ... */ }
val factory = object : FragmentFactory() {
override fun instantiate(classLoader: ClassLoader, className: String): Fragment {
return when (loadFragmentClass(classLoader, className)) {
MyFragment::class.java -> MyFragment(FakeUserRepository())
else -> super.instantiate(classLoader, className)
}
}
}
val scenario = launchFragmentInContainer<MyFragment>(factory = factory)
または、単一インスタンスの末尾のラムダを受け取る、ファクトリーを使用しないインラインオーバーロードを使います。
val scenario = launchFragmentInContainer { MyFragment(FakeUserRepository()) }
- 6.
moveToStateでライフサイクルを操作し、onFragmentで状態を確認します。 動作はActivityScenarioと同じです。
import androidx.lifecycle.Lifecycle
scenario.moveToState(Lifecycle.State.STARTED) // pause
scenario.onFragment { fragment -> // UI thread
assertThat(fragment.someState).isEqualTo("ready")
}
scenario.recreate() // configuration-change emulation
scenario.moveToState(Lifecycle.State.DESTROYED) // terminal
initialState = Lifecycle.State.DESTROYED は、IllegalArgumentException: Cannot set initial Lifecycle state to DESTROYED for FragmentScenario で拒否されます。これは fragment-testing/src/main/.../FragmentScenario.kt:496-498 の require(...) によるものです(R2 の 699~718 行)。代わりに CREATED で起動してから、moveToState(DESTROYED) を呼び出してください。
- 7. ホルダーフィールドを使わず、
withFragment { }でスレッド間の状態を取得します。withFragmentはonFragmentのサスペンド対応版/戻り値を返す版で、例外と戻り値を適切に伝播します(R2 の 670~680 行)。
val viewLifecycleOwner = scenario.withFragment { viewLifecycleOwner }
val isAdded = scenario.withFragment { isAdded }
- 8.
findNavController()の制限を把握します。EmptyFragmentActivityはNavHostFragmentをインストールしません。ホストされたフラグメントからfindNavController()を呼び出すと、IllegalStateException: Fragment ... does not have a NavController setがスローされます(R2 の 852~874 行)。回避策:
// Option A: TestNavHostController on the fragment view before onStart
launchFragmentInContainer {
MyFragment().also { fragment ->
fragment.viewLifecycleOwnerLiveData.observeForever { vlo ->
if (vlo != null) {
val testNav = TestNavHostController(ApplicationProvider.getApplicationContext())
testNav.setGraph(R.navigation.my_graph)
Navigation.setViewNavController(fragment.requireView(), testNav)
}
}
}
}
// Option B: write a custom test host Activity that hosts NavHostFragment, declared in
// src/androidTest/AndroidManifest.xml, and use ActivityScenarioRule on it instead.
- 9. シナリオを閉じます。
FragmentScenarioはCloseableです。
launchFragmentInContainer<MyFragment>().use { scenario ->
scenario.onFragment { /* ... */ }
}
close() は Activity を DESTROYED まで遷移させます。DESTROYED の後に onFragment を呼び出すと、IllegalStateException: The fragment has been removed from the FragmentManager already. がスローされます(FragmentScenario.kt:308-310、R2 の 815~821 行)。
パターン
パターン:間違い vs 正しい — デフォルトテーマを使った AppCompat フラグメント
// WRONG
val scenario = launchFragmentInContainer<LoginFragment>()
// The default FragmentScenarioEmptyFragmentActivityTheme extends android:Theme.WithActionBar,
// which is the platform Holo theme. Inflating any AppCompat / Material widget crashes:
// IllegalArgumentException: You need to use a Theme.AppCompat theme (or descendant) with the design library
// RIGHT
val scenario = launchFragmentInContainer<LoginFragment>(
themeResId = R.style.Theme_MyApp, // an AppCompat (or Material) theme
)
パターン:間違い vs 正しい — 誤った設定での fragment-testing-manifest
// WRONG
dependencies {
androidTestImplementation("androidx.fragment:fragment-testing:1.8.5")
androidTestImplementation("androidx.fragment:fragment-testing-manifest:1.8.5")
}
// WRONG because: the manifest artifact must be on debugImplementation (or testImplementation
// for host tests) so the EmptyFragmentActivity declaration is merged into the test APK
// manifest. On androidTestImplementation it isn't merged; launchFragmentInContainer crashes
// with ActivityNotFoundException for EmptyFragmentActivity.
// RIGHT
dependencies {
androidTestImplementation("androidx.fragment:fragment-testing:1.8.5")
debugImplementation("androidx.fragment:fragment-testing-manifest:1.8.5")
}
パターン:間違い vs 正しい — initialState = DESTROYED
// WRONG
launchFragmentInContainer<MyFragment>(initialState = Lifecycle.State.DESTROYED)
// WRONG because: FragmentScenario.kt:496-498 rejects DESTROYED with
// IllegalArgumentException: Cannot set initial Lifecycle state to DESTROYED for FragmentScenario
// RIGHT
val scenario = launchFragmentInContainer<MyFragment>(initialState = Lifecycle.State.CREATED)
scenario.moveToState(Lifecycle.State.DESTROYED) // moveToState DOES allow DESTROYED (terminal)
パターン:間違い vs 正しい — FragmentScenario がホストするフラグメントからの findNavController()
// WRONG
launchFragmentInContainer<MyFragment>().onFragment { fragment ->
fragment.findNavController().navigate(R.id.action_to_detail)
}
// WRONG because: EmptyFragmentActivity has no NavHostFragment; the lookup throws
// IllegalStateException: Fragment ... does not have a NavController set
// RIGHT
launchFragmentInContainer {
MyFragment().also { fragment ->
fragment.viewLifecycleOwnerLiveData.observeForever { vlo ->
if (vlo != null) {
val nav = TestNavHostController(ApplicationProvider.getApplicationContext())
nav.setGraph(R.navigation.my_graph)
Navigation.setViewNavController(fragment.requireView(), nav)
}
}
}
}
必須ルール
- 必須:
androidx.fragment:fragment-testing-manifestをdebugImplementation(インストルメンテーション)またはtestImplementation(ホスト)に追加してください。androidTestImplementationでは、マニフェストのエントリーはマージされません。 - 必須: AppCompat/Material ウィジェットを使用するフラグメントでは、
themeResIdを必ずオーバーライドしてください。デフォルトのテーマはプラットフォームの Holo テーマです。 - 禁止:
EmptyFragmentActivityがホストするフラグメントから、ビューにTestNavHostControllerを設定せずにfindNavController()を呼び出さないでください。 - 禁止:
initialState = Lifecycle.State.DESTROYEDを渡さないでください。FragmentScenario.kt:496-498 でIllegalArgumentExceptionがスローされ、拒否されます。 - 禁止:
moveToState(DESTROYED)またはclose()の後にonFragment/withFragmentを呼び出さないでください。IllegalStateExceptionがスローされます。 - 禁止: メインスレッドから
launchFragmentInContainer/launchFragment/moveToStateを呼び出さないでください。これらはインストルメンテーションのバリアでawait(...)を実行するため、UI スレッドから呼び出すとデッドロックします(ActivityScenarioと同じ制約です。../launching-activities-with-activityscenario/SKILL.mdを参照)。 - 推奨: 一般的な単一フラグメントのケースでは、独自に
FragmentFactoryを用意するより、インラインのファクトリーラムダlaunchFragmentInContainer { MyFragment(FakeRepo()) }を使用してください。 - 推奨:
close()がテスト失敗時にも実行されるよう、場当たり的な起動はuse { }で囲んでください。
検証
-
grep -r "fragment-testing-manifest" build.gradle*の結果で、debugImplementation(またはtestImplementation)に追加されており、androidTestImplementationには追加されていないことを確認します。 - AppCompat/Material フラグメントに対して、明示的な
themeResId =を指定せずにlaunchFragmentInContainer<F>()を呼び出すテストがないことを確認します。 - ランチャーに
initialState = Lifecycle.State.DESTROYEDを渡すテストがないことを確認します。 -
findNavController()を使用するテストでは、moveToState(STARTED)の前にTestNavHostControllerを設定します。 -
./gradlew :<module>:connectedDebugAndroidTestが、ActivityNotFoundException: EmptyFragmentActivityまたはYou need to use a Theme.AppCompat themeを発生させずに実行されることを確認します。 -
onFragment { }/withFragment { }ブロックの外に、リークしたフラグメント参照がないことを確認します。
参考資料
- Android Developers — フラグメントのテスト(FragmentScenario):https://developer.android.com/guide/fragments/test
- Android Developers — フラグメントのテストに関するリファレンス:https://developer.android.com/reference/androidx/fragment/app/testing/FragmentScenario
- AndroidX Fragment のリリースノート:https://developer.android.com/jetpack/androidx/releases/fragment
fragment/fragment-testing/src/main/java/androidx/fragment/app/testing/FragmentScenario.kt— 実装全体。263-323 行(launchFragmentInContainer)、325-389 行(launchFragment)、393-462 行(moveToState/recreate/onFragment)、496-498 行(DESTROYED の拒否)、504-508 行(インテントとテーマのエクストラ)。fragment/fragment-testing-manifest/src/main/java/androidx/fragment/app/testing/EmptyFragmentActivity.ktの 28-55 行 —super.onCreateの前にインテントからテーマを読み取るホストアクティビティ。fragment/fragment-testing-manifest/src/main/AndroidManifest.xmlの 568-580 行 —<activity android:name="androidx.fragment.app.testing.EmptyFragmentActivity" android:theme="@style/FragmentScenarioEmptyFragmentActivityTheme" android:exported="true" />。fragment/fragment-testing-manifest/src/main/res/values/styles.xmlの 469 行 —<style name="FragmentScenarioEmptyFragmentActivityTheme" parent="android:Theme.WithActionBar">。tasks/research/R2-scenario.md— FragmentScenario の詳細レポート。247-389 行(API の概要)、466-501 行(テーマに関する注意点)、813-919 行(ドキュメントに記載された 12 個の落とし穴)。docs/CORPUS.mdセクション H.4 — FragmentScenario の API の概要とテーマに関する注意点の要約。