Basic testing NavigationController in Android - android

I'm currently new to testing, so I decided to start off with some basic stuff.
I handle all my navigations from a DrawerLayout that is connected to an Activity.
So for my testing I launch an ActivityScenarioRule, create a testNavController object and then I set this testNavController to the current view that handles the navigation (The container fragment).
So the test consists on opening the drawer, clicking on menu item(Will navigate to a fragment) and therefore check if navigated to the fragment.
Then I check if that happened, but the testNavController stays on the same destination which is weird because it performs the click, so I decided to check the navController (The real one inside the activity), and it shows me that navigated to the correct fragment.
Here's the needed code:
#LargeTest
#RunWith(AndroidJUnit4::class)
class MapsActivityTest {
#get:Rule
var activityScenarioRule = ActivityScenarioRule(MapsActivity::class.java)
#Test
fun clickOnDrawerMaps_NavigateToAboutAppFragment() {
//Create TestNavHostController
val testNavController = TestNavHostController(ApplicationProvider.getApplicationContext())
UiThreadStatement.runOnUiThread { // This needed because it throws a exception that method addObserver must be called in main thread
testNavController.setGraph(R.navigation.nav_graph)
}
val scenario = activityScenarioRule.scenario
var navcontroller : NavController? = null
scenario.onActivity {mapsActivity ->
navcontroller = mapsActivity.navController //Get the real navController just to debug
mapsActivity.navController = testNavController //Set the test navController
Navigation.setViewNavController(mapsActivity.binding.containerFragment, testNavController)
}
onView(withId(R.id.drawerLayout)).perform(DrawerActions.open()).check(matches(isOpen()))
onView(withId(R.id.aboutAppFragment)).perform(click())
assertThat(testNavController.currentDestination?.id).isEqualTo(R.id.aboutAppFragment)
}
}
In the example they use a Fragment, which they set the fragment.requireView() on the launch of the fragment, but I think it's exactly the same.
What am I doing wrong here?

When you use ActivityScenario (or ActivityScenarioRule), your activity is brought all the way up to the resumed state before any onActivity calls are made. This means that your real NavController has already been created and used when you call setupWithNavController. This is why your call to setViewNavController() has no effect.
For these types of integration tests (where you have a real NavController), you should not use TestNavHostController.
As per the Test Navigation guide, TestNavHostController is designed for unit tests where you do not have any real NavController at all, such as when testing one fragment in isolation.

Related

How do I Restore Navigation State on Startup (Jetpack Compose)?

When I start up my app I want it to be able to restore the user's last session if it wasn't completed. How do I restore the navigation backstack while using Jetpack Compose?
My application presents educational sessions, with pages being generated dynamically. Back/up navigation works fine.
#Composable
fun SessionScreenLayout(
pageIndex: Int,
mainViewModel: MainViewModel,
) {
...
}
If a session is not completed when the app shuts down then I manually store that session and I want to be able to restore it when the user starts up again. Importantly I want back navigation to navigate sequentially backwards through my Session pages, after I have restored a session
I've tried populating the backstack but creating a new NavBackStackEntry seems to have 2 options. First doesn't work because the new NavBackStackEntry has the same id as the currentBackStackEntry. After populating the back stack I navigate to the last page and that works fine. but if I navigate backwards the page doesn't recompose properly:
val entry = NavBackStackEntry(
entry = currentBackStackEntry!!,
arguments = Bundle().apply {
...
}
)
navController.backQueue.addLast(entry)
navController.navigate(Screen.SessionScreen.withIndex(userSession.highestPageIndexViewed))
Else have tried using NavBackStackEntry.create():
val entry = NavBackStackEntry.create(
hostLifecycleState = ?,
viewModelStoreProvider = ?,
...
)
navController.backQueue.addLast(entry)
navController.navigate(Screen.SessionScreen.withIndex(userSession.highestPageIndexViewed))
With this I need the "viewModel" and "lifecycleOwner" private fields values from the navController for the constructor AFAIK. Without those I'm getting an exception. Again the last navigation works, but when I go back:
java.lang.IllegalStateException: You must call setViewModelStore() on your NavHostController before accessing the ViewModelStore of a navigation graph.
How do I get this working?

Android Unit test: Error: EmptyFragmentActivity cannot be cast to BaseActivity. How to get Instance of an Activity which is not defined in Manifest?

I am new to writing Android Test Cases. I am trying to write a test case for a project which is already developed. It has so many Activities and Fragments and we are using Navigation Graph for navigating the fragments.
I have started writing test cases from the first fragment and the code is as below:
val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
val scenario = launchFragmentInContainer<SplashFragment>()
scenario.onFragment{
// Get Auth Navigation
navController.setGraph(R.navigation.auth_navigation)
Navigation.setViewNavController(it.requireView(), navController)
it.redirectUser()
}
Now my SplashFragment contains few methods which have checks like
fun getAppName(): String {
return (activity as BaseActivity).getAppName()
}
Here, the BaseActivity is the activity which extends AppCompatActivity and it is not defined in my AndroidManifest file.
When I execute the test, I am getting an exception like:
androidx.fragment.app.testing.FragmentScenario$EmptyFragmentActivity
cannot be cast to com.xxx.ui.base.BaseActivity
Can anyone please help me with this?

How to destroy a ViewModel when user leave a screen

In my project I have a splash screen, when it is displayed, my app loading some startup data from server, after loading the data shows another screen.
For splash screen I create a ViewModel, but it stays in memory all the time. How to destroy it correctly?
Thank you for help!
#HiltViewModel
class SplashViewModel #Inject constructor (private val repository: Repository) {
....
}
#Composable
fun SplashScreen(vm: SplashViewModel) {
...
}
#Composable
fun Navigate() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "splash") {
composable("splash") {
SplashScreen(vm = hiltViewModel())
}
composable("main") {
MainScreen(...) // When shows MainScreen, SplashViewModel object still is in memory
}
}
}
Your viewmodel stays in memory because your splash screen is your root destination, and as such it stays always on the stack as the bottom entry.
If you want your splash viewmodel to be automatically destroyed when you leave your splash screen you should pop it from the backstack when you navigate to your main screen, using popUpTo.
Another option you could consider is to make your main screen the root destination and then navigate from that screen to splash if you are starting the app fresh.
Using hiltViewModel and scoping the viewmodel to the nav graph destination as you do will ensure the viewmodel is destroyed when the user leaves that screen, provided it's not in the backstack.
It is not explicitly supported in Android as far as I know. However, you could create a method named onViewModelCleared() inside the viewmodel itself and pass null to all the nullable objects, and something lightweight to non-null objects.

Navigating between Fragments in Espresso UI test

I am trying to write a UI test with Espresso that runs across multiple Fragments. They share a viewModel injected via by activityViewModels().
I am using Android Navigation component, Dagger Hilt injection and a mocked NavController like this:
val navController = Mockito.mock(NavController::class.java)
launchFragmentInHiltContainer<AddRecipeFragment1> {
Navigation.setViewNavController(requireView(), navController)
}
// Here I need to initialize some data in viewModel...
After this I want to navigate to another Fragment that I actually want to test, but neither pressing a button that calls the navigation nor calling navController.navigate(AddRecipeFragment1Directions.toAddRecipeFragment2()) works for me.
An approach using launchFragmentInHiltContainer<AddRecipeFragment>... will restart the whole application and drop the viewModel.
Any ideas?
Meanwhile I found a workaround doing this:
private lateinit var viewModel: AddRecipeViewModel
#Before
fun beforeEach() {
...
navController = Mockito.mock(NavController::class.java)
launchFragmentInHiltContainer<AddRecipeFragment2> {
val viewModelTemp: AddRecipeViewModel by activityViewModels()
viewModel = viewModelTemp
Navigation.setViewNavController(requireView(), navController)
}
}
This way I can at least access the delegated ViewModel directly in my tests.

NavController currentDestination is null?

I am using NavController to manages app navigation:
findNavController().navigate(action)
I got a few crashes in Crashlytics: I found it is because:
MyFragment {
...
myLiveData.observer(viewLifecycleOwner, Observer) {
findNavController().navigate(myAction) // currentDestination is null ...
})
...
navController.currentDestination? is an optional, When it is null, app crashes with unhandled exception.
Since currentDestination is declared as optional, I guess there must be some legit reason why it could be null, that I don't know. Appreciate in advance for any pointer.
I was experiencing the same issue.
At seemingly random times, the navigate to my destination fragment would crash due to the currentDestination being null.
Similar to the OP, I was triggering the nav through a Flow (not a live data).
Despite collecting the flow with the viewLifecycleOwner, it almost seemed like the fragment wasn't ready to navigate. What I found that fixed the issue was a little surprising. It was how the previous fragment was "popping" itself.
FragA -> FragB
FragB.popBackStack()
FragA -> VERY Quickly re-nav to FragB (null currentDestination == Crash)
However, as a test, I tried using
FragB.popBackStack(fragA.id, false)
And the crashes stopped. The currentDestination was never null again.
This must be a bug in the navComponent library.
My fix was as follows, and is still working (fingers crossed).
Instead of "findNavController.popBackStack()" I use
findNavController().previousBackStackEntry?.let {
findNavController().popBackStack(it.destination.id, false)
} ?: run {
findNavController().popBackStack()
}
Hope that works for someone else also.
edit Left in for posterity.. but.. I was wrong. this didn't fix it afterall. My mistake. Carry on.
Destination represents the node in the NavGraph that's being hosted by the NavHost. NavController just manages the flow. There are few ocasions when NavHost is not showing any destination e.g.:
before you set the NavGraph (because destination represents position in the graph)
when you manually inflate something in the NavHost using transaction (outside of the graph's scope)
If you have multiple graphs in one app (e.g. nested graphs, but can also be independent) you may have one NavController giving main graph destination and a secondary one returning null, etc.
Thanks Stachu, any relationship to fragment viewLifecycle?
In my case, the navigation is triggered from a liveData observer, i.e.,
MyFragment {
...
myLiveData.observer(viewLifecyucleOwner, Observer) {
findNavController().navigate(myAction) // currentDestination is null ...
}
...

Categories

Resources