How to test a Fragment with a ViewModel scoped in a graph - android

I´m using the navGraphViewModels ViewModel scoping on Android, and when I´m implementing the Fragment tests I can´t even start the test.
I´m using mockito to mock the NavigationController and using the documentation suggested aproach with the fragmentScenario. The problem comes when the ViewModel is tryed to be created that throws an Exception because NavController#getBackStackEntry is not mocked and I can´t mock it because NavController is a final class.
How can I test that uses ViewModels which are scoped to a navigation graph?

After a lot of time I found the answer.
You should change the fragment NavController by one that you have created and changed it ViewModelStore. An example of this is found in the test of the Android Source code.
val scenario = launchFragmentInContainer<TestVMFragment>()
navController.setViewModelStore(ViewModelStore())
scenario.onFragment { fragment ->
Navigation.setViewNavController(fragment.requireView(), navController)
}
}

Related

Android: Fragment restore with BottomNavigationView, NavController and SafeArgs

i'm currently working on an Android app and encountered a problem concerning BottomNavigationView and Fragments. I know, there are similar questions like mine but either they doesn't solve my problem or they have no working answers.
My app consists of five top-level destination fragments. For navigating between them I use the BottomNavigationView. Additionally, I have several fragments which serve as lower-level destinations and will be called from one of the top-level fragments. I use SafeArgs plugin to navigate to these fragments and also to pass data to.
My BottomNavigationView Configuration looks like this:
val navView: BottomNavigationView = findViewById(R.id.nav_view)
val navController = (supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment).navController
val appBarConfiguration = AppBarConfiguration(setOf(
R.id.navigation_dest1, R.id.navigation_dest2, R.id.navigation_dest3,
R.id.navigation_dest4, R.id.navigation_dest5))
setupActionBarWithNavController(navController, appBarConfiguration)
navView.setupWithNavController(navController);
The problem of this usage is that BottomNavigationView doesn't seem to provide support for saving and storing the fragments somewhere and reuse these instances for navigation. It just creates a new instance and displays it.
Currently each fragment contains some data fetching code, e.g. running a network request in a coroutine or loading files from the filesystem. And because BottomNavigationView doesn't preserve fragment instances, these data fetching parts are run too often.
Of course I thought about putting the data fetching process into the main activity but this results in an overall slower app-startup and doesn't solve the problem that the fragments still need to be recreated every time the user navigates between them.
Up to this point, I already found half of a solution. By using the SupportFragmentManager, manually adding, showing and hiding my fragments it works. But the app runs noticeably slower and the navigation to my lower-level destinations with SafeArgs just doesn't work anymore. I use SafeArgs because it's easy to use and pretty hassle-free, and I would like to keep using it.
I tried to manage it all manually with SupportFragmentManager, but it ends up in chaos and worse performance.
Is there any known way my problem can be solved? A way, BottomNavigationView can interact with SafeArgs and SupportFragmentManager to reuse the fragments instead of recreating them on each navigation action?
(If you need further information or parts of my code, please ask. I think posting my complete code here doesn't make much sense.)
Have you considered the option of sharing a ViewModel with your fragments ? For example:
Create a ViewModel class like the following:
class MyViewModel: ViewModel() {
....
....
}
Then, because your fragments share the same Activity, you can declare the following (in Kotlin):
class MyFragment1: Fragment() {
val viewModel: MyViewModel by activityViewModels()
....
....
}
class MyFragment2: Fragment() {
val viewModel: MyViewModel by activityViewModels()
....
....
}
In this case Fragment1 and Fragment2 will share the same ViewModel instance and the ViewModel will remain in memory until the activity is destroyed.
Fragments won't be preserved when you navigate out, but you can preserve all data of each fragment and re-use them. It is fast and smooth and you won't mind if the fragment is re-created because all its data will be kept in memory and ready for use in the shared ViewModel.
See also the official documentation:
ViewModel Overview

Android send ViewModel to Fragment via NavDirections arguement (Safe-Args)

I have an app with the following architecture:
Navigator is a custom class that holds the NavController
Cooridnator holds the Navigator
Cooridnator tells the Navigator to "start" the framgent and passes the ViewModel to it
Navigator asks NavController to navigateTo a NavDirections and provides the required arguments (using Safe-Args)
Now the issue here is that if I want to send the ViewModel as argument, it needs to be Parcelable and all of its underlying classes as well (which would make most of my code Parcelable, and that's not really needed).
So is there a way to do this without making everything Parcelable or using Dagger ? (Don't like Dagger as it adds too much complexity to the code...)
I would be okay with having a lateinit field in the Fragment and setting it manually but can't seem to access the Fragment from NavDirections
Any idea on how I could do this ?
First of all: what you are passing in safe args is "data" while your viewmodel is logic. Which means your data can change over the time (one of examples would be to become outdated) but as long as viewmodel is unchanged, it's logic would stay. Thus passing viewmodel itself does not make sense to me - best you can is to pass its snapshot of state, but I doubt that's what you want.
So yes, you should be using DI and there are alternatives to dagger complexity. You can experiment with koin (because I see kotlin in your tags list), some basic outline of what it can is here https://shorturl.at/bflFL (medium). You can also experiment with Hilt as what appears to be simplified alternative to Dagger, for android world.

injecting viewmodel with navigation-graph scope: NavController is not available before onCreate()

I'm using a navigation-component in my application and also using shared ViewModel between multiple fragments that are in the same graph. Now I want to instantiate the ViewModel with this graph scope with this.
As you know, in fragments we should inject objects ( ViewModel,..etc ) in onAttach:
but when I want to do this (injecting ViewModel with a graph scope in onAttach), this error occurs:
IllegalStateException: NavController is not available before onCreate()
Do you know how I can do this?
In short, you could provide the ViewModel lazily with dagger Provider or Lazy.
The long explanation is:
Your injections points are correct. According to https://dagger.dev/android#when-to-inject
DaggerActivity calls AndroidInjection.inject() immediately in
onCreate(), before calling super.onCreate(), and DaggerFragment does
the same in onAttach().
The problem is some kind of race condition between when Android recreates the Activity and the Fragments attached to the FragmentManger and when the NavController can be provided. More specifically:
one Activity that has Fragments attached is destroyed by the OS (can be reproduced with "don't keep Activities" from "developer settings")
user navigates back to the Activity, OS proceeds to recreate the Activity
Activity calls setContentView while being recreated.
This causes the Fragments in the FragmentManager to be reattached, which involve calling Fragment#onAttach
The Fragment is injected in Fragment#onAttach
Dagger tries to provide the NavController
BUT you cannot get the NavController from the Activity by this point, as Activity#onCreate has not finished yet and you get
IllegalStateException: NavController is not available before onCreate()
The solution I found is to inject provide the NavCotroller or things that depend on the NavController (such as the ViewModel, because Android needs the NavController to get nav-scoped VideModels) lazily. This can be done in two ways:
with Lazy
with Provided
(REF: https://proandroiddev.com/dagger-2-part-three-new-possibilities-3daff12f7ebf)
ie: inject the ViewModel to the Fragment or implementation of navigator like this:
#Inject
lateinit var viewModel: Provider<ViewModel>
then use it like this:
viewModel.get().events.observe(this) {....}
Now, the ViewModel can by provided by Dagger like:
#Provides
fun provideViewModel(
fragment: Fragment,
argumentId: Int
): CreateMyViewModel {
val viewModel: CreateMyViewModel
by fragment.navGraphViewModels(R.id.nested_graph_id)
return viewModel
}
Dagger won't try to resolve the provisioning when the Fragment is injected, but when it's used, hence, the race condition will be solved.
I really hate not being able to use my viewModels directly and need to use Provider, but it's the only workaround I see to solve this issue, which I'm sure it was an oversight by Google (I don't blame them, as keeping track of the absurd lifecycle of Fragment and Activities is so difficult).
...we should inject objects ( ViewModel,..etc ) in onAttach...
Looks like it is currently a no go for such injection with the original by navGraphViewModels(R.id.nav_graph) delegated property provided by androidx.navigation package because from the source code
findNavController().getBackStackEntry(navGraphId) and
public final NavController getNavController() it stated that:
* Returns the {#link NavController navigation controller} for this navigation host.
* This method will return null until this host fragment's {#link #onCreate(Bundle)}
And here are some workarounds:
https://github.com/InsertKoinIO/koin/issues/442

ViewModelScope is canceled permanently?

I am trying to use MVVM in my latest Android app. I am also using coroutines. I have ViewModel, that is injected into Activity using koin. To run coroutines in my ViewModel I am using ViewModelScope. Then after Activity is finished, ViewModel is cleared, and I will run this activity again - viewModelScope is canceled since the beginning.
That's very odd. As viewModelScope should be... reseted somehow or something? Or maybe my viewModelScope isn't closing correctly?
It would be helpful to see some code, but one possible thing to consider is - Are you injecting your ViewModel as a singleton with Koin?
single { MyViewModel() }
If so, this is your issue, as Koin is creating a single instance of your viewmodel and using this when you next load your activity. Change your Koin module to use the viewModel injection like so:
viewModel { MyViewModel() }

Android Espresso: How do I test a specific Fragment when following one activity to several fragment architecture

My app consists of one Activity for many Fragments.
I wish to use Espresso to test the UI of the Fragments. However I ran into a problem.
How can I test a Fragment which is not added to an Activity in onCreate. All examples I have seen with Fragments involve the Fragment being added in onCreate.
So how can I tell Espresso to go to a specific Fragment and start from there?
Thanks
If you are using the Navigation Architecture component, you can test each fragment instantly by Deep linking to the target fragment (with appropriate arguments) at the beginning of the test.
#Rule
#JvmField
var activityRule = ActivityTestRule(MainActivity::class.java)
protected fun launchFragment(destinationId: Int,
argBundle: Bundle? = null) {
val launchFragmentIntent = buildLaunchFragmentIntent(destinationId, argBundle)
activityRule.launchActivity(launchFragmentIntent)
}
private fun buildLaunchFragmentIntent(destinationId: Int, argBundle: Bundle?): Intent =
NavDeepLinkBuilder(InstrumentationRegistry.getInstrumentation().targetContext)
.setGraph(R.navigation.navigation)
.setComponentName(MainActivity::class.java)
.setDestination(destinationId)
.setArguments(argBundle)
.createTaskStackBuilder().intents[0]
destinationId being the fragment destination id in the navigation graph. Here is an example of a call that would be done once you are ready to launch the fragment:
launchFragment(R.id.target_fragment, targetBundle())
private fun targetBundle(): Bundle? {
val bundle = Bundle()
bundle.putString(ARGUMENT_ID, "Argument needed by fragment")
return bundle
}
Also answered in more detail here: https://stackoverflow.com/a/55203154/2125351
So, according to patterns and recommended practices by several companies. You need to write targeted and hermetic test for each view, whether it is activity, fragment, dialog fragment or custom views.
First you need to import the following libs into your project through gradle if you are using gradle in the following way
debugImplementation 'androidx.fragment:fragment-testing:1.2.0-rc03'
debugImplementation 'androidx.test:core:1.3.0-alpha03'
For Kotlin, debugImplementation 'androidx.test:core-ktx:1.3.0-alpha03'
In order to test fragment independently from activity, you can start/launch it in the following way:
#Test
fun sampleTesting(){
launchFragmentInContainer<YourFragment>()
onView(withId(R.id.sample_view_id)).perform(click())
}
This way, you can independently test your fragment from your activity and it is also one of recommended way to achieve hermetic and targeted ui test. For full details, you can read the fragment testing documentation from android docs
https://developer.android.com/training/basics/fragments/testing
And I found this repository with lots of test example useful even though it is extremely limited to complexity of test cases with super simple tests. Though it can be a guide to start.
https://github.com/android/testing-samples
Just show the Fragment using the Activity's SupportFragmentManager.
For example (Kotlin) with ActivityTestRule:
#Rule
#JvmField
var activityRule = ActivityTestRule(MainActivity::class.java)
Just do this before your tests:
#Before
fun setup() {
activityRule.activity.supportFragmentManager.beginTransaction().replace(R.id.main_activity_container_for_your_fragments, FragmentToShow(), "fragment-tag").commitAllowingStateLoss()
Thread.sleep(500)
}
Espresso can test Fragments only if they are displayed. And that requires them to be displayed by an Activity.
With your current setup you'll have to use Espresso to click() your way (like a user would) to the Fragment you actually want to test.
In one of my projects I have a ViewPager that displays Fragments. For those Fragments I use a custom FragmentTestRule to test them in isolation. I can start each Fragment directly and use Espresso to test it. See this answer.
You could also:
Do not use Fragments. Activities are easier to test. You can test each Activity on its own. In most cases Fragments offer no advantage over Activities. Fragments just make the implementation and testing more difficult.
Enable your FragmentActivity to directly show a certain Fragment when it is created. E.g. by supplying a special intent extra to your FragmentActivity. But this would add testing code to your app, which is generally not a good solution.

Categories

Resources