I'm implementing Espresso tests. I'm using a Fragment with a NavGraph scoped ViewModel. The problem is when I try to test the Fragment I got an IllegalStateException because the Fragment does not have a NavController set. How can I fix this problem?
class MyFragment : Fragment(), Injectable {
private val viewModel by navGraphViewModels<MyViewModel>(R.id.scoped_graph){
viewModelFactory
}
#Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
//Other stuff
}
Test class:
class FragmentTest {
class TestMyFragment: MyFragment(){
val navMock = mock<NavController>()
override fun getNavController(): NavController {
return navMock
}
}
#Mock
private lateinit var viewModel: MyViewModel
private lateinit var scenario: FragmentScenario<TestMyFragment>
#Before
fun prepareTest(){
MockitoAnnotations.initMocks(this)
scenario = launchFragmentInContainer<TestMyFragment>(themeResId = R.style.Theme_AppCompat){
TestMyFragment().apply {
viewModelFactory = ViewModelUtil.createFor(viewModel)
}
}
// My test
}
Exception I got:
java.lang.IllegalStateException: View android.widget.ScrollView does not have a NavController setjava.lang.IllegalStateException
As can be seen in docs, here's the suggested approach:
// Create a mock NavController
val mockNavController = mock(NavController::class.java)
scenario = launchFragmentInContainer<TestMyFragment>(themeResId = R.style.Theme_AppCompat) {
TestMyFragment().also { fragment ->
// In addition to returning a new instance of our Fragment,
// get a callback whenever the fragment’s view is created
// or destroyed so that we can set the mock NavController
fragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
if (viewLifecycleOwner != null) {
// The fragment’s view has just been created
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
}
}
}
Thereafter you can perform verification on mocked mockNavController as such:
verify(mockNavController).navigate(SearchFragmentDirections.showRepo("foo", "bar"))
See architecture components sample for reference.
There exists another approach which is mentioned in docs as well:
// Create a graphical FragmentScenario for the TitleScreen
val titleScenario = launchFragmentInContainer<TitleScreen>()
// Set the NavController property on the fragment
titleScenario.onFragment { fragment ->
Navigation.setViewNavController(fragment.requireView(), mockNavController)
}
This approach won't work in case there happens an interaction with NavController up until onViewCreated() (included). Using this approach onFragment() would set mock NavController too late in the lifecycle, causing the findNavController() call to fail. As a unified approach which will work for all cases I'd suggest using first approach.
You are missing setting the NavController:
testFragmentScenario.onFragment {
Navigation.setViewNavController(it.requireView(), mockNavController)
}
Related
I have a ViewModel that I'm already injecting into a Composable. Now I want to inject the same instance of that ViewModel into my Activity. For example:
In AccountScreen.kt
#Composable
fun AccountScreen(accountViewModel: AccountViewModel = hiltViewModel()) {
...
}
and my Activity class:
class MainActivity : ComponentActivity() {
#Inject
lateinit var accountViewModel: AccountViewModel
}
should have the same instance of AccountViewModel.
I know using #Inject in the Activity as in the example above doesn't work. Hilt's documentation suggests using ViewModelProvider or by viewModels() instead, both of which give me a new instance of AccountViewModel, but I need the same instance as what's in the AccountScreen Composable.
I'm assuming AccountScreen is part of a NavGraph, since you mentioned you need same instance of the view model, you can consider specifying the ViewModelStoreOwner when you inject your ViewModel in your AccountScreen, so MainActivity and AccountScreen will share same instance of it.
#Composable
fun MyNavHost(
...
) {
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
}
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable(<Destination>) {
AccountScreen(accountViewModel: AccountViewModel = hiltViewModel(viewModelStoreOwner)) {
...
}
}
...
}
}
I ended up solving this by getting the parent Activity's ViewModel in my child Composable (AccountScreen in this case) like so:
val composeView = LocalView.current
val activityViewModel = composeView.findViewTreeViewModelStoreOwner()?.let {
hiltViewModel<MyViewModel>(it)
}
Within my MainActivity I'm getting the ViewModel the standard way
private val accountViewModel: AccountViewModel by viewModels()
Thanks to #z.g.y for providing a helpful suggestion that led me to this solution.
I want to write ui test for my fragment. Now I am using hilt for dependency injection and navigation components .My ui test code is like this.
#HiltAndroidTest
#RunWith(AndroidJUnit4::class)
class WelcomeFragmentTest {
#get:Rule
val hiltRule = HiltAndroidRule(this)
private val navController = TestNavHostController(ApplicationProvider.getApplicationContext())
#Before
fun setUp(){
hiltRule.inject()
}
#Test
fun `testFragmentinits`(){
launchWelcomeFragment()
}
private fun launchWelcomeFragment() {
launchFragmentInHiltContainer<WelcomeFragment> {
navController.setGraph(R.navigation.nav_graph)
navController.setCurrentDestination(R.id.welcomeFragment)
this.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
if (viewLifecycleOwner != null) {
// The fragment’s view has just been created
Navigation.setViewNavController(this.requireView(), navController)
}
}
}
}
}
After runnig test i got this error
It is because you use different activity in your test rather than using your real activity. in your fragment, there is some code that casting the activity that you get in that fragment to your real activity. this doesn't work because you are using different activity in your test that is HiltTestActivity. try to cast it to activity class that is more general like Activity or AppCompatActivity.
I want to achieve the communication between fragment and its host activity by using ViewModel(following: Share data using a ViewModel) to update the UI of activity when shared LiveData changed.
Start with declare ViewModel in the module
MainModule.kt
object MainModule {
val module = module {
viewModel {
MainViewModel()
}
}
}
Then inject it to activity and fragment
MainActivity.kt
private val mainViewModel by viewModel<MainViewModel>()
MainFragment.kt
private val mainViewModel by sharedViewModel<MainViewModel>()
Observe the change of LiveData on activity
MainActivity.kt
mainViewModel.drawerState.observe(this, {
// do something when it changed
})
Update the LiveData when the button(on fragment) clicked
MainFragment.kt
mainButton.setOnClickListener {
mainViewModel.toggleDrawerState()
}
The LiveData declare in ViewModel
MainViewModel.kt
private val _drawerState = MutableLiveData<DrawerState>()
val drawerState: LiveData<DrawerState> = _drawerState
fun toggleDrawerState() {
if (_drawerState.value == DrawerState.OPENED) {
_drawerState.value = DrawerState.CLOSED
} else {
_drawerState.value = DrawerState.OPENED
}
}
DrawerState.kt
enum class DrawerState {
CLOSED, OPENED
}
But It does not work as expected which means nothing happens when the button clicked(can guarantee by debugging with breakpoint). I wondering to know where I've gone wrong or misunderstood. Thank you.
I am using navigation component for jetpack compose in my app like this:
#Composable
fun FoodiumNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.Main.route,
) {
composable(Screen.Main.route) {
MainScreen(navController)
}
...
}
}
And I am getting viewmodel in my MainScreen composable like this:
#Composable
fun MainScreen(navController: NavController) {
val mainViewModel: MainViewModel = viewModel()
...
}
which is giving me a runtime exception as Cannot create an instance of class com.package.main.MainViewModel.
Here, I am stating that this only happens while using navigation component, i.e. everything was working fine and mainViewModel was successfully instantiated before using navigation component in my app.
The MainViewModel is like this:
#ExperimentalCoroutinesApi
#HiltViewModel
class MainViewModel #Inject constructor(private val postRepository: PostRepository) :
ViewModel() {
private val _postsLiveDataState = MutableLiveData<UiState<List<Post>>>()
val postLiveState: LiveData<UiState<List<Post>>> = _postsLiveDataState
init {
getPostsState()
}
private fun getPostsState() {
viewModelScope.launch {
postRepository.getAllPosts()
.onStart { _postsLiveDataState.value = UiState(loading = true) }
.map { resource -> UiState.fromResource(resource) }
.collect { state -> _postsLiveDataState.value = state }
}
}
}
If your #HiltViewModel is scoped to the navigation graph use hiltNavGraphViewModel() instead of viewModel() to initialize. For more reference android documentaion
Update
hiltNavGraphViewModel() is now deprecated, use hiltViewModel() instead
Thanks to Narek Hayrapetyan for the reminder
hiltNavGraphViewModel is deprecated, should be used hiltViewModel() instead
also add dependency androidx.hilt:hilt-navigation-compose:1.0.0-alpha03
You should add this
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
then you can use this code for create instance of your viewmodel
val viewModel: YourViewModelClass= hiltViewModel()
You can use viewModel() as well, but check that owning Activity or Fragment has been annotated with #AndroidEntryPoint.
This video (MVVM & Nested Fragments/Views: ViewModel Contracts - By Marcos Paulo Damesceno, Bret Erickson
droidcon San Francisco 2019
) shows a way to deal with communication between activities/fragments using ViewModel.
I am implementing it for learning purpose but I got stuck.
// 18:35 of the video
private const val VM_KEY = "view_model_contract_key"
fun <T> Fragment.viewModelContracts() = lazy {
val clazz: Class<ViewModel> = arguments?.getSerializable(VM_KEY) as Class<ViewModel>
val viewModelProvider = ViewModelProvider(requireActivity())
return#lazy viewModelProvider.get(clazz) as T
}
The ViewModelStoreOwner passed as parameter is an Activity, but if I have a Fragment inside another Fragment where both of them share the same ViewModel, the ViewModel returned by viewModelContracts() will be a different object as the one created by the Parent Fragment.
interface ChildViewModelContract {
// ...
}
class SomeViewModel : ViewModel(), ChildViewModelContract {
// ...
}
class ParentFragment: Fragment {
private val viewModel: SomeViewModel by viewModels()
// ...
}
class ChildFragment: Fragment {
private val viewModelContract: ChildViewModelContract by viewModelContracts()
// ...
}
The ideal solution would be to check in fun <T> Fragment.viewModelContracts() if the ViewModelProvider of the parent fragment has the ViewModel stored in it, and if not, use the ViewModelProvider of the Activity. But I'm not knowing how to do this.
fun <T> Fragment.viewModelContracts() = lazy {
val clazz: Class<ViewModel> = arguments?.getSerializable(VM_KEY) as Class<ViewModel>
val parentFragment = parentFragment
if (parentFragment != null) {
val viewModelProvider = ViewModelProvider(parentFragment)
// is there any way to do something like this?
if (viewModelProvider.isViewModelStored(clazz)) {
return#lazy viewModelProvider.get(clazz) as T
}
}
val viewModelProvider = ViewModelProvider(requireActivity())
return#lazy viewModelProvider.get(clazz) as T
}