Search code examples
androidandroid-espressoback-buttonandroid-jetpack-navigationinstrumented-test

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 ?


Solution

  • 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.