关于 launching-fragments-with-fragmentscenario
这项 Android 测试技能说明如何用 FragmentScenario 独立启动 Fragment,并覆盖容器与无容器模式、主题、工厂和生命周期控制。适用于编写 Fragment 仪器测试及排查常见限制。
主要功能
- 01容器内与无容器启动
- 02传入参数与自定义主题
- 03注入 FragmentFactory
- 04控制并检查生命周期
- 05说明导航控制器限制
适用场景
- 01单独测试 Fragment 生命周期与视图
- 02为 AppCompat 界面配置测试主题
- 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 启动 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 表面和主题陷阱摘要。