mcpField
L
launching-fragments-with-fragmentscenario

launching-fragments-with-fragmentscenario

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

launching-fragments-with-fragmentscenario について

FragmentScenario で Android の Fragment を単独起動するテストスキルです。コンテナの有無、テーマやファクトリーの設定、ライフサイクル制御、ナビゲーションの制約を扱います。

主な機能

  1. 01コンテナ内・なしでの起動
  2. 02引数とカスタムテーマの指定
  3. 03FragmentFactory の注入
  4. 04ライフサイクルの制御と確認
  5. 05ナビゲーションの制約の把握

活用シーン

  1. 01Fragment のライフサイクルとビューの単体テスト
  2. 02AppCompat UI 用テストテーマの設定
  3. 03コンストラクター引数を持つ Fragment のテスト
  4. 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 の概要とテーマに関する注意点の要約。

コメント