Home > Software engineering >  How can I launch a fragment with safe args in an instrumentation test?
How can I launch a fragment with safe args in an instrumentation test?

Time:02-13

Below I have a test class designed to launch a fragment in isolation and test the navController's ability to navigate.

The first test, landingToGameFragmentTest() works perfectly!

The second test launches a fragment that depends on safe args to be passed to it. Aside from that, there is no difference I can perceive in how they are executed.

// Declare navController at top level so it can be accessed from any test in the class
private lateinit var navController: TestNavHostController

// Use Generic type with fragment as upper bound to pass any type of FragmentScenario
private fun <T : Fragment> init(scenario: FragmentScenario<T>) {

    // Create a test navController
    navController = TestNavHostController(
        ApplicationProvider.getApplicationContext()
    )

    scenario.onFragment { fragment ->
        // Link navController to its graph
        navController.setGraph(R.navigation.nav_graph)
        // Link fragment to its navController
        Navigation.setViewNavController(fragment.requireView(), navController)
    }
}

@Test
fun landingToGameFragmentTest() {

    init(launchFragmentInContainer<LandingFragment>(themeResId = THEME))

    // Click button to navigate to GameFragment
    onView(withId(R.id.button_start_game))
        .perform(click())

    assertEquals("Navigation to GameFragment failed",
        R.id.gameFragment,
        navController.currentDestination?.id)
}

@Test
fun gameToLandingFragmentTest() {

    init(launchFragmentInContainer<GameFragment>(themeResId = THEME, fragmentArgs = Bundle()))

    onView(withId(R.id.button_end_game))
        .perform(click())

    assertEquals("Navigation to LandingFragment failed",
        R.id.landingFragment,
        navController.currentDestination?.id)
}

I set a default value for its arguments, but I still got a null arguments exception until I passed it an empty bundle. Now the fragment will launch but it appears not to be able to navigate to any other fragment!

I could find no similar questions on SO, and the stack output is beyond me.

After the init(launchFragmentInContainer()) line I stepped through the code and found this throwing an illegalArgumentException:

public static int parseInt(@RecentlyNonNull String s, int radix) throws NumberFormatException {
    throw new RuntimeException("Stub!");
}

Which then leads to getNavigator(), which passes the name "fragment". However the only navigators are "navigation" and "test", of which I believe it should be test. The illegalStateException is then thrown:

/**
 * Retrieves a registered [Navigator] by name.
 *
 * @param name name of the navigator to return
 * @return the registered navigator with the given name
 *
 * @throws IllegalStateException if the Navigator has not been added
 *
 * @see NavigatorProvider.addNavigator
 */
@Suppress("UNCHECKED_CAST")
@CallSuper
public open fun <T : Navigator<*>> getNavigator(name: String): T {
    require(validateName(name)) { "navigator name cannot be an empty string" }
    val navigator = _navigators[name]
        ?: throw IllegalStateException(
            "Could not find Navigator with name \"$name\". You must call "  
                "NavController.addNavigator() for each navigation type."
        )
    return navigator as T
}

Finally navigate() is called on onView(withId(R.id.button_end_game)) generating:

illegalArgumentException on navigate()

I likely don't have to keep this test in this specific instance. However, I certainly will need to know how to launch a Fragment in isolation (that depends on safe args) in the future.

Thank you for your consideration!!

CodePudding user response:

You have two entirely separate problems here:

Your Fragment needs to have arguments passed to it.

Arguments are passed to your fragment via the fragmentArgs parameter of launchFragmentInContainer as explained in the Fragment testing guide.

Each Args class, such as your LandingFragmentArgs has a constructor that lets you construct that Args class directly. You can then use the toBundle() method to make the Bundle that you pass to launchFragmentInContainer:

val args = GameFragmentArgs(/* pass in your required args here */)
val bundle = args.toBundle()
init(launchFragmentInContainer<GameFragment>(fragmentArgs = bundle, themeResId = THEME))

Your NavController needs to have its state set to the GameFragment destination

Your TestNavHostController doesn't know that your test needs to start on the destination associated with GameFragment - by default, it will just be on the startDestination of your graph (where whatever action you are trying to trigger doesn't exist).

As per the Test Navigation documentation:

TestNavHostController provides a setCurrentDestination method that allows you to set the current destination so that the NavController is in the correct state before your test begins.

So you need to make sure you call setCurrentDestination after your init call:

val args = GameFragmentArgs(/* pass in your required args here */)
val bundle = args.toBundle()
val scenario = launchFragmentInContainer<GameFragment>(fragmentArgs = bundle, themeResId = THEME)
init(scenario)

// Ensure that the NavController is set to the expected destination
// using the ID from your navigation graph associated with GameFragment
scenario.onFragment {
  // Just like setGraph(), this needs to be called on the main thread
  navController.setCurrentDestination(R.id.game_fragment, bundle)
}
  • Related