mcpField
L
launching-fragments-with-fragmentscenario

launching-fragments-with-fragmentscenario

github.com/skydoves测试与质量33962 次安装22 周前更新
代码仓库

关于 launching-fragments-with-fragmentscenario

这项 Android 测试技能说明如何用 FragmentScenario 独立启动 Fragment,并覆盖容器与无容器模式、主题、工厂和生命周期控制。适用于编写 Fragment 仪器测试及排查常见限制。

主要功能

  1. 01容器内与无容器启动
  2. 02传入参数与自定义主题
  3. 03注入 FragmentFactory
  4. 04控制并检查生命周期
  5. 05说明导航控制器限制

适用场景

  1. 01单独测试 Fragment 生命周期与视图
  2. 02为 AppCompat 界面配置测试主题
  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 启动 Fragment — 独立测试 Fragment

FragmentScenario 会在内部的 EmptyFragmentActivity 中托管单个 fragment,因此无需真实屏幕即可测试 fragment 的 onCreate / onViewCreated / 生命周期。它提供两个入口:launchFragmentInContainer<F>() 会将 fragment 添加到 android.R.id.content(包含完整生命周期和视图),launchFragment<F>() 则会在无容器的情况下附加 fragment(无界面)。默认主题继承自 android:Theme.WithActionBar,而不是 AppCompat —— 使用任何 AppCompat 控件时,都必须通过 themeResId 覆盖主题。本技能说明了这两个 artifact、主题陷阱和导航限制。

何时使用此技能

  • 用户想在不启动完整宿主 Activity 的情况下测试单个 Fragment。
  • 测试崩溃,并显示 IllegalArgumentException: You need to use a Theme.AppCompat theme (or descendant) with the design library,且 fragment 使用 AppCompat / Material 控件。
  • 测试调用 findNavController() 后崩溃,并显示 Fragment ... does not have a NavController set。
  • 用户想驱动 fragment 的生命周期(STARTED / RESUMED / DESTROYED)来测试状态变化。
  • 用户想为没有无参构造函数的 fragment 注入自定义 FragmentFactory。
  • 用户提到“无界面 fragment”,或询问为什么 onViewCreated 没有触发。

何时不使用此技能

  • 宿主是 Activity,而不是 Fragment —— 请参阅 ../launching-activities-with-activityscenario/SKILL.md。
  • fragment 需要真实的导航栈(NavHostFragment)—— FragmentScenario 不提供导航栈;请改用配置了 NavHostFragment 的自定义测试宿主 Activity,或使用 TestNavHostController。
  • 尚未配置运行器 / 依赖项 —— 请从 ../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md 开始。
  • fragment 托管 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 的 manifest 条目。Android M+ 要求将此 artifact 添加到 debugImplementation(宿主测试则添加到 testImplementation),以便 manifest merger 纳入 EmptyFragmentActivity。
  • 来自 ../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md 的运行器配置。

工作流

  • 1. 在正确的配置中添加这两个制品。 在 Android M 及更高版本上,拆分为两个制品是不可妥协的:
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() 的 Fragment 传入 fragmentArgs。
val args = bundleOf("user_id" to 42, "from_deep_link" to true)
val scenario = launchFragmentInContainer<UserDetailFragment>(fragmentArgs = args)
  • 4. 对于 AppCompat / Material Fragment,将 themeResId 覆盖为 AppCompat 主题。 默认主题 FragmentScenarioEmptyFragmentActivityTheme 继承自 android:Theme.WithActionBar(平台 Holo 主题),而非 Theme.AppCompat。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 之前调用,因此在任何 Fragment 视图开始布局加载之前,覆盖的主题就已生效(R2 第 490-501 行)。

  • 5. 为使用非默认构造函数的 Fragment 注入 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)

或者使用不带工厂的内联重载,该重载接受一个单实例尾随 lambda:

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。从托管的 Fragment 调用 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 行)。

模式

模式:错误做法与正确做法 — 使用默认主题的 AppCompat Fragment
// 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
)
模式:错误做法与正确做法 — 将 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")
}
模式:错误做法与正确做法 — 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)
模式:错误做法与正确做法 — 从由 FragmentScenario 托管的 Fragment 调用 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(instrumentation)或 testImplementation(host)。添加到 androidTestImplementation 时,manifest 条目不会合并。
  • 必须为任何使用 AppCompat / Material 组件的 fragment 覆盖 themeResId。默认主题是平台的 Holo 主题。
  • 不得在由 EmptyFragmentActivity 托管的 fragment 中调用 findNavController(),除非先在其 view 上设置 TestNavHostController。
  • 不得传入 initialState = Lifecycle.State.DESTROYED——它会在 FragmentScenario.kt:496-498 处因 IllegalArgumentException 被拒绝。
  • 不得在调用 moveToState(DESTROYED) 或 close() 后调用 onFragment / withFragment——这会抛出 IllegalStateException。
  • 不得从主线程调用 launchFragmentInContainer / launchFragment / moveToState——这些方法会 await(...) 一个 instrumentation 屏障,从 UI 线程调用会导致死锁(与 ActivityScenario 的限制相同;参见 ../launching-activities-with-activityscenario/SKILL.md)。
  • **推荐:**对于常见的单 fragment 场景,优先使用内联工厂 lambda launchFragmentInContainer { MyFragment(FakeRepo()) },而不是手动编写 FragmentFactory。
  • **推荐:**使用 use { } 包装临时启动,确保测试失败时也会执行 close()。

验证

  • grep -r "fragment-testing-manifest" build.gradle* 显示该依赖位于 debugImplementation(或 testImplementation),绝不位于 androidTestImplementation。
  • 对于 AppCompat/Material fragment,没有测试在未显式设置 themeResId = 的情况下调用 launchFragmentInContainer<F>()。
  • 没有测试向启动器传入 initialState = Lifecycle.State.DESTROYED。
  • 使用 findNavController() 的测试会在调用 moveToState(STARTED) 前安装 TestNavHostController。
  • ./gradlew :<module>:connectedDebugAndroidTest 运行时不会出现 ActivityNotFoundException: EmptyFragmentActivity 或 You need to use a Theme.AppCompat theme。
  • 没有 fragment 引用泄漏到 onFragment { } / withFragment { } 块之外。

参考资料

  • Android Developers — 测试 fragment(FragmentScenario):https://developer.android.com/guide/fragments/test
  • Android Developers — Fragment 测试参考: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 行(intent + theme extras)。
  • fragment/fragment-testing-manifest/src/main/java/androidx/fragment/app/testing/EmptyFragmentActivity.kt 第 28-55 行 — 托管 activity,会在调用 super.onCreate 之前从 intent 中读取主题。
  • 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 表面和主题陷阱摘要。

评论

支持 Markdown