Search code examples
androidandroid-fragmentsandroid-architecture-navigationandroid-fragmentscenario

FragmentScenario and nested NavHostFragments don't perform navigations as expected in Instrumentation tests


I am writing a single Activity app that uses Android's Navigation Components to help with navigation and Fragment Scenario for instrumentation testing. I have run into a performance discrepancy when using the back button between the actual app navigation behavior and the behavior of a Fragment being tested in isolation during an Instrumentation tests when using fragment scenario.

In my MainActivity I have a main NavHostFragment that takes up the entire screen. I use that nav host fragment to show several screens including some master detail fragments. Each master detail fragment has another NavHostFragment in it to show the different detail fragments for that feature. This setup works great and provides the behavior I desire.

To accomplish the master detail screen I use a ParentFragment that has two FrameLayouts to create the split screen for tablet and for handset I programatically hide one of the FrameLayouts. When the ParentFragment is created, it detects if it is being run on a tablet or handset and then programatically adds a NavHostFragment to the right frame layout on tablet, and on handset hides the right pane adds a NavHostFragment to the left pane. The NavHostFragments also have a different navigation graph set on them depending on if they are being run on tablet or handset (on handset we show fragments as dialogs, on tablet we show them as regular fragments).

 private fun setupTabletView() {
        viewDataBinding.framelayoutLeftPane.visibility = View.VISIBLE
        if (navHostFragment == null) {
            navHostFragment = NavHostFragment.create(R.navigation.transport_destinations_tablet)
            navHostFragment?.let {
                childFragmentManager.beginTransaction()
                    .add(R.id.framelayout_left_pane, it, TRANSPORT_NAV_HOST_TAG)
                    .setPrimaryNavigationFragment(it)
                    .commit()
            }
        }

        if (childFragmentManager.findFragmentByTag(SummaryFragment.TAG) == null) {
            childFragmentManager.beginTransaction()
                .add(R.id.framelayout_right_pane, fragFactory.instantiate(ClassLoader.getSystemClassLoader(), SummaryFragment::class.java.canonicalName!!), SummaryFragment.TAG)
                .commit()
        }
    }

    private fun setupPhoneView() {
        viewDataBinding.framelayoutLeftPane.visibility = View.GONE
        if (navHostFragment == null) {
            navHostFragment = NavHostFragment.create(R.navigation.transport_destinations_phone)
            navHostFragment?.let {
                childFragmentManager.beginTransaction()
                        .replace(R.id.framelayout_left_pane, it, TRANSPORT_NAV_HOST_TAG)
                    .setPrimaryNavigationFragment(it)
                    .commit()
            }
        }
    }

When running the devDebug version of the app, everything works as expected. I am able to navigate using the main NavHostFragment to different master-detail screens. After I navigate to the master-detail screen, the nested NavHostFragment takes over and I can navigate screens in and out of the master detail fragment using the nested NavHostFragment.

When the user attempts to click the back button, which would cause the to leave the master detail screen and navigate to the previous screen, we pop up a dialog to the user asking if they really want to leave the screen (it's a screen where they enter a lot of data). To accomplish this we register an onBackPressDispatcher callback so we know when the back button was pressed and navigate to the dialog when the callback is invoked. In the devDebug version, the user begins by being at location A on the nav graph. If, when they are at location A, they click the back button, then we show a dialog fragment asking if the user really intends to leave the screen. If, instead, the user navigates from location A to location B and clicks back they are first navigated back to location A. If they click the back button again, the back press dispatcher callback is invoked and they are then shown the dialog fragment asking if they really intent to leave location A. So it seems that that the back button affects the back stack of the nested NavHostFragment until the nested NavHostFragment only has one fragment left. When only one fragment is left and the back button is clicked, the onBackPressDisapatcher callback is invoked. This is exactly the desired behavior. However, when I write an Instrumentation test with Fragment Scenario where I attempt to test the ParentFragment I have found that the back press behavior is different. In the test I use Fragment Scenario to launch ParentFragment, I then run a test where I do a navigation in the nested NavHostFragment. When I click the back button I expect that the nested nav host fragment will pop its stack. However, the onBackPressDispatcher callback is invoked immediately instead of after the nested nav host fragment has one fragment left on its stack.

I set some breakpoints in the NavHostFragment and it seems that when the tests are run, the NavHostFragment is not setup to intercept back clicks. Its enableOnBackPressed() method is always called with a flag set to false.

I don't understand what about the test setup is causing this behavior. I would think that the nav host fragment would intercept the back clicks itself until it only had one fragment left on its backstack and only then would the onBackPressDispatcher callback be invoked.

Am I misunderstanding how I should be testing this? Why does the onBackPressDispatcher's callback get called when the back button is pressed.


Solution

  • As seen in the FragmentScenario source code, it does not currently (as of Fragment 1.2.1) use setPrimaryNavigationFragment(). This means that the Fragment being tested does not intercept the back button and hence, its child fragments (such as your NavHostFragment) do not intercept the back button.

    You can set this flag yourself in your test:

    @Test
    fun testParentFragment() {
        // Use the reified Kotlin extension to launchFragmentInContainer
        with(launchFragmentInContainer<ParentFragment>()) {
            onFragment { fragment ->
                // Use the fragment-ktx commitNow Kotlin extension
                fragment.parentFragmentManager.commitNow {
                    setPrimaryNavigationFragment(fragment)
                }
            }
            // Now you can proceed with your test
        }