Reputation: 463
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
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
Reputation: 463
The reason for FragmentB's OnBackPressedCallback
to be ignored is the way how OnBackPressedDispatcher
treats its OnBackPressedCallback
s. 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