0

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!!

Marcin Orlowski
  • 72,056
  • 11
  • 123
  • 141
JHerscu
  • 68
  • 4
  • Did you read [the documentation](https://developer.android.com/guide/navigation/navigation-testing)? "`TestNavHostController` provides a [`setCurrentDestination`](https://developer.android.com/reference/kotlin/androidx/navigation/testing/TestNavHostController#setCurrentDestination(kotlin.Int,android.os.Bundle)) method that allows you to set the current destination so that the NavController is in the correct state before your test begins." – ianhanniballake Feb 13 '22 at 01:55
  • Yes I have. I read that prior to writing the test. I suppose my issue is not understanding how the safe args component creates the bundle then? – JHerscu Feb 13 '22 at 02:10
  • I'm not sure how to format it. Every time I try to write my own bundle modeled after the safe args component, it tells me I am passing null arguments. – JHerscu Feb 13 '22 at 02:11

1 Answers1

3

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)
}
ianhanniballake
  • 191,609
  • 30
  • 470
  • 443
  • Thank you! I can't believe I didn't think to structure the bundle as GameFragmentArgs(..args..)! That certainly clears up my confusion in that aspect. I was trying to pass a normal bundle with identical names. – JHerscu Feb 13 '22 at 02:53
  • However, when I call setCurrentDestination() I still receive the same exception that I did beforehand when calling it. `java.lang.IllegalStateException: Method setCurrentState must be called on the main thread` – JHerscu Feb 13 '22 at 02:55
  • I had found it in the documentation but couldn't figure out why I kept getting that error, so I'd decided to pursue other solutions in the meantime. – JHerscu Feb 13 '22 at 02:56
  • Ah, yep. `setCurrentDestination`, just like `setGraph`, needs to be on the main thread. You could either make that part of your `init` method (which is generally the correct way of doing it) or use `scenario.onFragment` or any other method in your individual test to get that call onto the main thread. – ianhanniballake Feb 13 '22 at 02:59
  • Ah, I just found the solution at https://stackoverflow.com/questions/64686001/android-navigation-instrumentation-test-throws-method-addobserver-must-be-called – JHerscu Feb 13 '22 at 02:59
  • Gotcha! Thank you so much for your time and assistance! – JHerscu Feb 13 '22 at 03:00