Piotr Śmietana
Piotr Śmietana

Reputation: 463

android jetpack navigation instrumented test fail on back navigation

I've created a simple, two fragment example app using jetpack Navigation component (androidx.navigation). First fragment navigates to second one, which overrides backbutton behavior with OnBackPressedDispatcher.

activity layout

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:padding="@dimen/box_inset_layout_padding"
    tools:context=".navigationcontroller.NavigationControllerActivity">

    <fragment
        android:name="androidx.navigation.fragment.NavHostFragment"
        android:id="@+id/nav_host"
        android:layout_width="match_parent"
        android:layout_height="match_parent"

        app:defaultNavHost="true"
        app:navGraph="@navigation/nav_graph" />
</LinearLayout>

FragmentA:

class FragmentA : Fragment() {

    lateinit var buttonNavigation: Button

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_a, container, false)
        buttonNavigation = view.findViewById<Button>(R.id.button_navigation)
        buttonNavigation.setOnClickListener { Navigation.findNavController(requireActivity(), R.id.nav_host).navigate(R.id.fragmentB) }
        return view
    }
}

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".navigationcontroller.FragmentA">
    
    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="fragment A" />

    <Button
        android:id="@+id/button_navigation"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="go to B" />
</LinearLayout>

FragmentB:

class FragmentB : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_b, container, false)
        requireActivity().onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                val textView = view.findViewById<TextView>(R.id.textView)
                textView.setText("backbutton pressed, press again to go back")
                this.isEnabled = false
            }
        })
        return view
    }
}

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".navigationcontroller.FragmentA">
    
    <TextView
        android:id="@+id/textView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:text="fragment B" />
</FrameLayout>

Intended behavior of backbutton in FragmentB (first touch changes text without navigation, second navigates back) works fine when I test the app manually. I've added instrumented tests to check backbutton behavior in FragmentB and that's where problems started to arise:

class NavigationControllerActivityTest {

    lateinit var fragmentScenario: FragmentScenario<FragmentB>
    lateinit var navController: TestNavHostController

    @Before
    fun setUp() {
        navController = TestNavHostController(ApplicationProvider.getApplicationContext())

        fragmentScenario = FragmentScenario.launchInContainer(FragmentB::class.java)
        fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
            override fun perform(fragment: FragmentB) {
                Navigation.setViewNavController(fragment.requireView(), navController)
                navController.setLifecycleOwner(fragment.viewLifecycleOwner)
                navController.setOnBackPressedDispatcher(fragment.requireActivity().getOnBackPressedDispatcher())
                navController.setGraph(R.navigation.nav_graph)
                // simulate backstack from previous navigation
                navController.navigate(R.id.fragmentA)
                navController.navigate(R.id.fragmentB)
            }
        })
    }

    @Test
    fun whenButtonClickedOnce_TextChangedNoNavigation() {
        Espresso.pressBack()
        onView(withId(R.id.textView)).check(matches(withText("backbutton pressed, press again to go back")))
        assertEquals(R.id.fragmentB, navController.currentDestination?.id)
    }

    @Test
    fun whenButtonClickedTwice_NavigationHappens() {
        Espresso.pressBack()
        Espresso.pressBack()
        assertEquals(R.id.fragmentA, navController.currentDestination?.id)
    }
}

Unfortunately, while whenButtonClickedTwice_NavigationHappens passes, whenButtonClickedOnce_TextChangedNoNavigation fails due to text not being changed, just like OnBackPressedCallback was never called. Since app works fine during manual tests, there must be something wrong with test code. Can anyone help me ?

Upvotes: 2

Views: 1637

Answers (2)

ianhanniballake
ianhanniballake

Reputation: 200010

If you're trying to test your OnBackPressedCallback logic, it is better to do that directly, rather than try to test the interaction between Navigation and the default activity's OnBackPressedDispatcher.

That would mean that you'd want to break the hard dependency between the activity's OnBackPressedDispatcher (requireActivity().onBackPressedDispatcher) and your Fragment by instead injecting in the OnBackPressedDispatcher, thus allowing you to provide a test specific instance:

class FragmentB(val onBackPressedDispatcher: OnBackPressedDispatcher) : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val view = inflater.inflate(R.layout.fragment_b, container, false)
        onBackPressedDispatcher.addCallback(object : OnBackPressedCallback(true) {
            override fun handleOnBackPressed() {
                val textView = view.findViewById<TextView>(R.id.textView)
                textView.setText("backbutton pressed, press again to go back")
                this.isEnabled = false
            }
        })
        return view
    }
}

This allows you to have your production code provide a FragmentFactory:

class MyFragmentFactory(val activity: FragmentActivity) : FragmentFactory() {
    override fun instantiate(classLoader: ClassLoader, className: String): Fragment =
        when (loadFragmentClass(classLoader, className)) {
            FragmentB::class.java -> FragmentB(activity.onBackPressedDispatcher)
            else -> super.instantiate(classLoader, className)
        }
}

// Your activity would use this via:
override fun onCreate(savedInstanceState: Bundle?) {
    supportFragmentManager.fragmentFactory = MyFragmentFactory(this)
    super.onCreate(savedInstanceState)
    // ...
}

This would mean you could write your tests such as:

class NavigationControllerActivityTest {

    lateinit var fragmentScenario: FragmentScenario<FragmentB>
    lateinit var onBackPressedDispatcher: OnBackPressedDispatcher
    lateinit var navController: TestNavHostController

    @Before
    fun setUp() {
        navController = TestNavHostController(ApplicationProvider.getApplicationContext())

        // Create a test specific OnBackPressedDispatcher,
        // giving you complete control over its behavior
        onBackPressedDispatcher = OnBackPressedDispatcher()

        // Here we use the launchInContainer method that
        // generates a FragmentFactory from a constructor,
        // automatically figuring out what class you want
        fragmentScenario = launchFragmentInContainer {
            FragmentB(onBackPressedDispatcher)
        }
        fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
            override fun perform(fragment: FragmentB) {
                Navigation.setViewNavController(fragment.requireView(), navController)
                navController.setGraph(R.navigation.nav_graph)
                // Set the current destination to fragmentB
                navController.setCurrentDestination(R.id.fragmentB)
            }
        })
    }

    @Test
    fun whenButtonClickedOnce_FragmentInterceptsBack() {
        // Assert that your FragmentB has already an enabled OnBackPressedCallback
        assertTrue(onBackPressedDispatcher.hasEnabledCallbacks())

        // Now trigger the OnBackPressedDispatcher
        onBackPressedDispatcher.onBackPressed()
        onView(withId(R.id.textView)).check(matches(withText("backbutton pressed, press again to go back")))

        // Check that FragmentB has disabled its Callback
        // ensuring that the onBackPressed() will do the default behavior
        assertFalse(onBackPressedDispatcher.hasEnabledCallbacks())
    }
}

This avoids testing Navigation's code and focuses on testing your code and specifically your interaction with OnBackPressedDispatcher.

Upvotes: 1

Piotr Śmietana
Piotr Śmietana

Reputation: 463

The reason for FragmentB's OnBackPressedCallback to be ignored is the way how OnBackPressedDispatcher treats its OnBackPressedCallbacks. They are run as chain-of-command, meaning that most recently registered one that is enabled will 'eat' the event so others will not receive it. Therefore, most recently registered callback inside FragmentScenario.onFragment() (which is enabled by lifecycleOwner, so whenever Fragment is at least in lifecycle STARTED state. Since fragment is visible during the test when backbutton is pressed, callback is always enabled at the time), will have priority over previously registered one in FragmentB.onCreateView(). Therefore, TestNavHostController's callback must be added before FragmentB.onCreateView() is executed.

This leads to changes in test code @Before method:

@Before
fun setUp() {
    navController = TestNavHostController(ApplicationProvider.getApplicationContext())

    fragmentScenario = FragmentScenario.launchInContainer(FragmentB::class.java, initialState = Lifecycle.State.CREATED)
    fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
        override fun perform(fragment: FragmentB) {
            navController.setLifecycleOwner(fragment.requireActivity())
            navController.setOnBackPressedDispatcher(fragment.requireActivity().getOnBackPressedDispatcher())
            navController.setGraph(R.navigation.nav_graph)
            // simulate backstack from previous navigation
            navController.navigate(R.id.fragmentA)
            navController.navigate(R.id.fragmentB)
        }
    })
    fragmentScenario.moveToState(Lifecycle.State.RESUMED)
    fragmentScenario.onFragment(object : FragmentScenario.FragmentAction<FragmentB> {
        override fun perform(fragment: FragmentB) {
            Navigation.setViewNavController(fragment.requireView(), navController)
        }
    })
}

Most important change is to launch Fragment in CREATED state (instead of default RESUMED) to be able to tinker with it before onCreateView().

Also, notice that Navigation.setViewNavController() is run in separate onFragment() after moving fragment to RESUMED state - it accepts View parameter, so it cannot be used before onCreateView()

Upvotes: 1

Related Questions