Search code examples
androidkotlinandroidxandroid-fragmentscenario

FragmentScenario doesn't work as expected


I'm using almost the as same architecture as Google sample: GithubBrowserSample.

This field is injected in my Fragment class:

@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory

val viewModel: TrainingListViewModel by viewModels {
    viewModelFactory
}

In all my fragments tests I got this error which corresponds to a field that is instantiated by injection:

java.lang.RuntimeException: kotlin.UninitializedPropertyAccessException: lateinit property viewModelFactory has not been initialized
at androidx.test.runner.MonitoringInstrumentation.runOnMainSync(MonitoringInstrumentation.java:441)
at androidx.test.core.app.ActivityScenario.onActivity(ActivityScenario.java:564)
at androidx.fragment.app.testing.FragmentScenario.internalLaunch(FragmentScenario.java:300)
at androidx.fragment.app.testing.FragmentScenario.launchInContainer(FragmentScenario.java:282)
at com.maximesarrato.lafayapp.ui.training.TrainingListFragmentTest.init(TrainingListFragmentTest.kt:213)
at java.lang.reflect.Method.invoke(Native Method)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
at androidx.test.internal.runner.junit4.statement.RunBefores.evaluate(RunBefores.java:76)
at org.junit.rules.TestWatcher$1.evaluate(TestWatcher.java:55)
at org.junit.rules.TestWatcher$1.evaluate(TestWatcher.java:55)
at org.junit.rules.TestWatcher$1.evaluate(TestWatcher.java:55)
at org.junit.rules.RunRules.evaluate(RunRules.java:20)
at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at androidx.test.ext.junit.runners.AndroidJUnit4.run(AndroidJUnit4.java:104)
at org.junit.runners.Suite.runChild(Suite.java:128)
at org.junit.runners.Suite.runChild(Suite.java:27)
at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
at org.junit.runner.JUnitCore.run(JUnitCore.java:115)
at androidx.test.internal.runner.TestExecutor.execute(TestExecutor.java:56)
at androidx.test.runner.AndroidJUnitRunner.onStart(AndroidJUnitRunner.java:392)
at android.app.Instrumentation$InstrumentationThread.run(Instrumentation.java:2189)
Caused by: kotlin.UninitializedPropertyAccessException: lateinit property viewModelFactory has not been initialized
at com.maximesarrato.lafayapp.ui.training.TrainingListFragment.getViewModelFactory(TrainingListFragment.kt:41)
at com.maximesarrato.lafayapp.ui.training.TrainingListFragment$viewModel$2.invoke(TrainingListFragment.kt:49)
at com.maximesarrato.lafayapp.ui.training.TrainingListFragment$viewModel$2.invoke(TrainingListFragment.kt:39)
at androidx.lifecycle.ViewModelLazy.getValue(ViewModelProvider.kt:52)
at androidx.lifecycle.ViewModelLazy.getValue(ViewModelProvider.kt:41)
at com.maximesarrato.lafayapp.ui.training.TrainingListFragment.getViewModel(Unknown Source:7)
at com.maximesarrato.lafayapp.ui.training.TrainingListFragmentTest$init$$inlined$launchFragmentInContainer$1.instantiate(FragmentScenario.kt:114)
at androidx.fragment.app.testing.FragmentScenario$1.perform(FragmentScenario.java:310)
at androidx.fragment.app.testing.FragmentScenario$1.perform(FragmentScenario.java:301)
at androidx.test.core.app.ActivityScenario.lambda$onActivity$2$ActivityScenario(ActivityScenario.java:551)
at androidx.test.core.app.ActivityScenario$$Lambda$4.run(Unknown Source:4)
at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:462)
at java.util.concurrent.FutureTask.run(FutureTask.java:266)
at android.app.Instrumentation$SyncRunnable.run(Instrumentation.java:2207)
at android.os.Handler.handleCallback(Handler.java:883)
at android.os.Handler.dispatchMessage(Handler.java:100)
at android.os.Looper.loop(Looper.java:214)
at android.app.ActivityThread.main(ActivityThread.java:7356)
at java.lang.reflect.Method.invoke(Native Method)
at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:492)
at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:930)

This happens when I set my fragment like this with FragmentScenario:

viewModel = mock(TrainingListViewModel::class.java)

val scenario = launchFragmentInContainer(themeResId = R.style.Theme_LafayWorkbook) {
    TrainingListFragment().apply {
        appExecutors = countingAppExecutors.appExecutors
        viewModelFactory = ViewModelUtil.createFor(viewModel)
    }
}

dataBindingIdlingResourceRule.monitorFragment(scenario)
scenario.onFragment { fragment ->
    Navigation.setViewNavController(fragment.requireView(), navController)
    fragment.binding.trainingListRv.itemAnimator = null
    fragment.disableProgressBarAnimations()
}

But when I setup the Fragment before using it with FragmentScenario it works:

viewModel = mock(TrainingListViewModel::class.java)
val trainingListFragment = TrainingListFragment()
trainingListFragment.viewModelFactory = ViewModelUtil.createFor(viewModel)
trainingListFragment.appExecutors = countingAppExecutors.appExecutors

trainingListFragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
    if (viewLifecycleOwner != null) {
        Navigation.setViewNavController(trainingListFragment.requireView(), navController)
    }
}

val scenario = launchFragmentInContainer(themeResId = R.style.Theme_LafayWorkbook) {
    trainingListFragment
}

Here is the source code of my test:

@RunWith(AndroidJUnit4::class)
class TrainingListFragmentTest {
    @Rule
    @JvmField
    val executorRule = TaskExecutorWithIdlingResourceRule()

    @Rule
    @JvmField
    val countingAppExecutors = CountingAppExecutorsRule()

    @Rule
    @JvmField
    val dataBindingIdlingResourceRule = DataBindingIdlingResourceRule()

    private val navController = mock<NavController>()
    private val trainingsLiveData = MutableLiveData<Resource<List<Training>>>()
    private val isPremiumLiveData = MutableLiveData<PremiumAccount>()
    private val trainingDeletedLiveData = MutableLiveData<Event<Boolean>>()
    private val navigateToCreateTrainingLiveData = MutableLiveData<Event<Boolean>>()
    private val navigateToExerciseListLiveData = MutableLiveData<Event<Training>>()
    private lateinit var viewModel: TrainingListViewModel

    @Before
    fun init() {
        viewModel = mock(TrainingListViewModel::class.java)
        val trainingListFragment = TrainingListFragment()
        trainingListFragment.viewModelFactory = ViewModelUtil.createFor(viewModel)
        trainingListFragment.appExecutors = countingAppExecutors.appExecutors

        trainingListFragment.viewLifecycleOwnerLiveData.observeForever { viewLifecycleOwner ->
            if (viewLifecycleOwner != null) {
                Navigation.setViewNavController(trainingListFragment.requireView(), navController)
            }
        }

        `when`(viewModel.trainingsLiveData).thenReturn(trainingsLiveData)
        `when`(viewModel.isPremium).thenReturn(isPremiumLiveData)
        `when`(viewModel.trainingDeleted).thenReturn(trainingDeletedLiveData)
        `when`(viewModel.navigateToCreateTraining).thenReturn(navigateToCreateTrainingLiveData)
        `when`(viewModel.navigateToExerciseList).thenReturn(navigateToExerciseListLiveData)

        val scenario = launchFragmentInContainer(themeResId = R.style.Theme_LafayWorkbook) {
            trainingListFragment
        }
        dataBindingIdlingResourceRule.monitorFragment(scenario)
        scenario.onFragment { fragment ->
            Navigation.setViewNavController(fragment.requireView(), navController)
            fragment.binding.trainingListRv.itemAnimator = null
            fragment.disableProgressBarAnimations()
        }
    }

}

Do you guys have an idea about why this is happening and how I can fix it? I'm really struggling on this :(


Solution

  • The problem you're experiencing is due to variable shadowing.

    When you write this code

    viewModel = mock(TrainingListViewModel::class.java)
    
    val scenario = launchFragmentInContainer(themeResId = R.style.Theme_LafayWorkbook) {
        TrainingListFragment().apply {
            appExecutors = countingAppExecutors.appExecutors
            viewModelFactory = ViewModelUtil.createFor(viewModel)
        }
    }
    

    The ViewModelUtil.createFor(viewModel) is not using the viewModel variable that you've created with mock above, but is instead using the viewModel variable **within your TrainingListFragment. This is because you are using apply { }, which means that the scope that the code applies to is the fragment itself - its effectively as if your code was within that fragment itself so viewModel is really TrainingListViewModel.this.viewModel.

    This is why your error message says

    at com.maximesarrato.lafayapp.ui.training.TrainingListFragment.getViewModel(Unknown Source:7)
    at com.maximesarrato.lafayapp.ui.training.TrainingListFragmentTest$init$$inlined$launchFragmentInContainer$1.instantiate(FragmentScenario.kt:114)
    

    You're calling getViewModel() i.e.., accessing the viewModel property from within your instantiate block.

    The easiest solution is to just use a different variable name for your mock viewModel instead of using the same name of viewModel. If it was named mockViewModel, then it calling ViewModelUtil.createFor(mockViewModel) would correctly reference your mock ViewModel since there wouldn't be any variable from your fragment with that same name.

    The other option is to use also instead of apply:

    viewModel = mock(TrainingListViewModel::class.java)
    
    val scenario = launchFragmentInContainer(themeResId = R.style.Theme_LafayWorkbook) {
        TrainingListFragment().also { fragment ->
            fragment.appExecutors = countingAppExecutors.appExecutors
            fragment.viewModelFactory = ViewModelUtil.createFor(viewModel)
        }
    }
    

    also, like apply, always returns the original object - your TrainingListFragment (this is correct and what you want to return).

    However, when using also you have to name the variable you're operating on (here I use fragment rather than the default it) and you need to specifically use fragment.viewModel if you wanted to refer to something within the fragment variable. Again, without any name shadowing, in this case viewModel would point to the only viewModel variable in scope - your mock ViewModel.