I'm trying to add test navigation to the example present in the documentation of conditional navigation.
I created a demo following the example and published it on github.
The demo contains 3 fragments: HomeFragment, ProfileFragment and LoginFragment. In the ProfileFragment there is a condition checking if the user is connected or not. If the user is not connected he is sent to the LoginFragment.
ProfileFragment
@AndroidEntryPoint
class ProfileFragment : Fragment(R.layout.fragment_profile) {
private val mainViewModel: MainViewModel by activityViewModels()
private val navController by lazy { findNavController() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val currentBackStackEntry = navController.currentBackStackEntry!!
currentBackStackEntry.savedStateHandle.getLiveData<Boolean>(LoginFragment.LOGIN_SUCCESSFUL)
.observe(currentBackStackEntry, { success ->
if (!success) {
val startDestination = navController.graph.startDestination
val navOptions = NavOptions.Builder()
.setPopUpTo(startDestination, true)
.build()
navController.navigate(startDestination, null, navOptions)
}
})
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mainViewModel.user.observe(viewLifecycleOwner, {
if(!it){
navController.navigate(R.id.loginFragment)
}
})
}
}
As explained in the documentation, in the onCreate() method I'm observing the LOGIN_SUCCESSFUL value stored in the SavedStateHandle in order to redirect the user if he didn't log in.
LoginFragment
@AndroidEntryPoint
class LoginFragment : Fragment() {
companion object {
const val LOGIN_SUCCESSFUL: String = "LOGIN_SUCCESSFUL"
}
private val viewModel: LoginViewModel by viewModels()
private val navController by lazy { findNavController() }
private lateinit var savedStateHandle: SavedStateHandle
private lateinit var binding: FragmentLoginBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding = DataBindingUtil.inflate(inflater, R.layout.fragment_login, container, false)
binding.viewModel = viewModel
binding.lifecycleOwner = this
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
savedStateHandle = navController.previousBackStackEntry!!.savedStateHandle
savedStateHandle.set(LOGIN_SUCCESSFUL, false)
viewModel.login.observe(viewLifecycleOwner, ResultObserver {
if (it.status == Status.ERROR) {
Snackbar.make(requireView(), it.message ?: "Error", Snackbar.LENGTH_SHORT).show()
} else if (it.status == Status.SUCCESS) {
savedStateHandle.set(LOGIN_SUCCESSFUL, true)
navController.popBackStack()
}
})
}
}
Testing
@Test
fun profileFragment_unauthenticated() {
setAuthentication(false)
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
launchFragmentInHiltContainer<ProfileFragment> {
navController.setViewModelStore(ViewModelStore())
navController.setGraph(R.navigation.nav_main)
navController.setCurrentDestination(R.id.profileFragment)
Navigation.setViewNavController(requireView(), navController)
}
assertEquals(navController.currentDestination?.id, R.id.loginFragment)
}
Because I'm using Hilt for dependencies injection I need to attach the fragment to an activity annotated with @AndroidEntryPoint: launchFragmentInHiltContainer
During the test, I'm creating an instance of TestNavHostController and assigning it to the fragment.
The problem is that I am accessing the navController in the onCreate method while the navController is instantiated during the RESUMED state (see FragmentScenario). Causing a java.lang.IllegalStateException: Fragment ProfileFragment ... does not have a NavController set
I know it's possible to make the navController available sooner by adding an observer to the fragment's viewLifecycleOwnerLiveData. However, it is not soon enough.
So, how to implement test navigation in such a case?
Possible solution
After several days looking for a way to add test to my fragment I thought I found a solution. The idea was to attach the navController to the activity. However, I'm not totally satisfied with the result, that is why I'm not posting this solution as an answer. For anyone interested I added my solution to the github repository.