Launching Fragments with FragmentScenario — Test Fragments in Isolation
FragmentScenario hosts a single fragment inside an internal EmptyFragmentActivity so the fragment's onCreate / onViewCreated / lifecycle can be exercised without a real screen. Two entry points exist: launchFragmentInContainer<F>() adds the fragment to android.R.id.content (full lifecycle including view), and launchFragment<F>() attaches it with no container (headless). The default theme extends android:Theme.WithActionBar, NOT AppCompat — overriding via themeResId is required for any AppCompat widget. This skill encodes the two artifacts, the theme trap, and the navigation limitation.
When to use this skill
- The user wants to test a single Fragment without launching the full host Activity.
- A test crashes with
IllegalArgumentException: You need to use a Theme.AppCompat theme (or descendant) with the design libraryand the fragment uses AppCompat / Material widgets. - A test calls
findNavController()and crashes withFragment ... does not have a NavController set. - The user wants to drive lifecycle (
STARTED/RESUMED/DESTROYED) on a fragment for state-change testing. - The user wants to inject a custom
FragmentFactoryfor a fragment that doesn't have a no-arg constructor. - The user mentions "headless fragment" or asks why
onViewCreateddoesn't fire.
When NOT to use this skill
- The host is an Activity, not a Fragment — see
../launching-activities-with-activityscenario/SKILL.md. - The fragment requires a real navigation stack (
NavHostFragment) —FragmentScenariodoes not provide one; use a custom test host activity that wires upNavHostFragmentinstead, orTestNavHostController. - The runner / dependency stack is not yet set up — start with
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md. - The fragment hosts Compose content and the test interacts via the Compose tree — see
../../../compose/setup/configuring-test-dependencies/SKILL.md.
Prerequisites
androidTestImplementation("androidx.fragment:fragment-testing:1.8.x")— the API:FragmentScenario,launchFragmentInContainer,launchFragment,withFragment.debugImplementation("androidx.fragment:fragment-testing-manifest:1.8.x")— the manifest entry that declaresEmptyFragmentActivity. Android M+ requires this artifact be ondebugImplementation(ortestImplementationfor host tests) so the manifest merger picks upEmptyFragmentActivity.- The runner stack from
../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md.
Workflow
- 1. Add both artifacts on the right configurations. The two-artifact split is non-negotiable on Android M+:
dependencies {
androidTestImplementation("androidx.fragment:fragment-testing:1.8.5")
debugImplementation("androidx.fragment:fragment-testing-manifest:1.8.5")
}
fragment-testing ships the API; fragment-testing-manifest ships only the <activity android:name="androidx.fragment.app.testing.EmptyFragmentActivity"> manifest entry plus the default theme. Without the manifest artifact, launchFragmentInContainer / launchFragment crash with ActivityNotFoundException: EmptyFragmentActivity. From tasks/research/R2-scenario.md lines 568-619.
- 2. Pick the launcher by view requirement.
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 uses containerViewId = android.R.id.content (isViewAttachedToWindow == true). launchFragment uses containerViewId = 0 (headless; isViewAttachedToWindow == false). Source: R2 lines 263-389, 683-684.
- 3. Pass
fragmentArgsfor fragments that readrequireArguments().
val args = bundleOf("user_id" to 42, "from_deep_link" to true)
val scenario = launchFragmentInContainer<UserDetailFragment>(fragmentArgs = args)
- 4. For AppCompat / Material fragments, override
themeResIdto an AppCompat theme. The default themeFragmentScenarioEmptyFragmentActivityThemeextendsandroid:Theme.WithActionBar(the platform Holo theme), NOTTheme.AppCompat. AppCompat widgets crash at inflation withYou need to use a Theme.AppCompat theme (or descendant) with the design library. From R2 lines 466-501:
val scenario = launchFragmentInContainer<MyAppCompatFragment>(
themeResId = R.style.Theme_MyApp, // any AppCompat / Material theme
)
EmptyFragmentActivity.setTheme is called BEFORE super.onCreate, so the override applies before any fragment view inflation runs (R2 lines 490-501).
- 5. Inject a
FragmentFactoryfor non-default-constructor fragments. Both launchers accept afactory: FragmentFactory?parameter (R2 lines 263-323). The factory is stored in aFragmentFactoryHolderViewModelkeyed off theEmptyFragmentActivity'sViewModelStore, which means it survivesrecreate():
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)
Or use the no-factory inline overload that accepts a single-instance trailing lambda:
val scenario = launchFragmentInContainer { MyFragment(FakeUserRepository()) }
- 6. Drive lifecycle with
moveToStateand inspect withonFragment. Same semantics asActivityScenario:
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 is rejected with IllegalArgumentException: Cannot set initial Lifecycle state to DESTROYED for FragmentScenario — the require(...) at fragment-testing/src/main/.../FragmentScenario.kt:496-498 (R2 lines 699-718). Launch with CREATED and then moveToState(DESTROYED) instead.
- 7. Capture cross-thread state via
withFragment { }instead of holder fields.withFragmentis the suspending/return-value variant ofonFragmentthat propagates exceptions and return values cleanly (R2 lines 670-680):
val viewLifecycleOwner = scenario.withFragment { viewLifecycleOwner }
val isAdded = scenario.withFragment { isAdded }
- 8. Recognize the
findNavController()limitation.EmptyFragmentActivitydoes NOT install aNavHostFragment.findNavController()from a hosted fragment throwsIllegalStateException: Fragment ... does not have a NavController set(R2 lines 852-874). Workarounds:
// 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. Close the scenario.
FragmentScenarioisCloseable:
launchFragmentInContainer<MyFragment>().use { scenario ->
scenario.onFragment { /* ... */ }
}
close() drives the Activity to DESTROYED. Calling onFragment after DESTROYED throws IllegalStateException: The fragment has been removed from the FragmentManager already. (FragmentScenario.kt:308-310, R2 lines 815-821).
Patterns
Pattern: WRONG vs RIGHT — AppCompat fragment with default theme
// 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
)
Pattern: WRONG vs RIGHT — fragment-testing-manifest on the wrong configuration
// 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")
}
Pattern: WRONG vs RIGHT — 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)
Pattern: WRONG vs RIGHT — findNavController() from a FragmentScenario-hosted fragment
// 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)
}
}
}
}
Mandatory rules
- MUST put
androidx.fragment:fragment-testing-manifestondebugImplementation(instrumentation) ortestImplementation(host). OnandroidTestImplementationthe manifest entry is not merged. - MUST override
themeResIdfor any fragment that uses AppCompat / Material widgets. The default theme is the platform Holo theme. - MUST NOT call
findNavController()from a fragment hosted byEmptyFragmentActivitywithout first setting aTestNavHostControlleron its view. - MUST NOT pass
initialState = Lifecycle.State.DESTROYED— it is rejected withIllegalArgumentExceptionat FragmentScenario.kt:496-498. - MUST NOT call
onFragment/withFragmentaftermoveToState(DESTROYED)orclose()— throwsIllegalStateException. - MUST NOT call
launchFragmentInContainer/launchFragment/moveToStatefrom the main thread — theyawait(...)an instrumentation barrier and deadlock from the UI thread (same constraint asActivityScenario; see../launching-activities-with-activityscenario/SKILL.md). - PREFERRED: use the inline factory lambda
launchFragmentInContainer { MyFragment(FakeRepo()) }over a hand-rolledFragmentFactoryfor the common single-fragment case. - PREFERRED: wrap ad-hoc launches in
use { }soclose()runs on test failure too.
Verification
-
grep -r "fragment-testing-manifest" build.gradle*shows it ondebugImplementation(ortestImplementation), neverandroidTestImplementation. - No test calls
launchFragmentInContainer<F>()for an AppCompat/Material fragment without an explicitthemeResId =. - No test passes
initialState = Lifecycle.State.DESTROYEDto a launcher. - Tests using
findNavController()install aTestNavHostControllerbeforemoveToState(STARTED). -
./gradlew :<module>:connectedDebugAndroidTestruns withoutActivityNotFoundException: EmptyFragmentActivityorYou need to use a Theme.AppCompat theme. - No leaked fragment reference outside
onFragment { }/withFragment { }blocks.
References
- Android Developers — Test your fragments (FragmentScenario): https://developer.android.com/guide/fragments/test
- Android Developers — Fragment testing reference: https://developer.android.com/reference/androidx/fragment/app/testing/FragmentScenario
- AndroidX Fragment release notes: https://developer.android.com/jetpack/androidx/releases/fragment
fragment/fragment-testing/src/main/java/androidx/fragment/app/testing/FragmentScenario.kt— full implementation; lines 263-323 (launchFragmentInContainer), 325-389 (launchFragment), 393-462 (moveToState/recreate/onFragment), 496-498 (DESTROYED rejection), 504-508 (intent + theme extras).fragment/fragment-testing-manifest/src/main/java/androidx/fragment/app/testing/EmptyFragmentActivity.ktlines 28-55 — host activity that reads the theme out of the intent BEFOREsuper.onCreate.fragment/fragment-testing-manifest/src/main/AndroidManifest.xmllines 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.xmlline 469 —<style name="FragmentScenarioEmptyFragmentActivityTheme" parent="android:Theme.WithActionBar">.tasks/research/R2-scenario.md— full FragmentScenario report. Lines 247-389 (API surface), 466-501 (theme trap), 813-919 (12 documented pitfalls).docs/CORPUS.mdSection H.4 — FragmentScenario API surface and theme trap summary.