androidandroid-jetpack-composeandroid-viewmodelandroid-architecture-navigationdagger-hilt

How to populate SavedStateHandle with navigation arguments in instrumentation test using Hilt and Jetpack Compose


I'm using a combination of Compose, Navigation, ViewModel and Hilt with SavedStateHandle to access nav arguments within the ViewModel. Everything works well together, however I've run into an issue when it comes to instrumentation testing.

Say I have a ViewModel:

@HiltViewModel
class MyViewModel @Inject constructor(
    application: Application,
    ...
    savedStateHandle: SavedStateHandle,
) : AndroidViewModel(application) {

    private val id = savedStateHandle.require<Long>("myId")
    ...
}

A Composable representing a screen that uses the ViewModel:

@Composable
fun MyScreen(viewModel: MyViewModel = hiltViewModel()) {
    ...
}

An instrumentation test that renders the composable using Hilt to provide the ViewModel and all of its dependencies:

@HiltAndroidTest
class MyTest {

    @get:Rule(order = 0)
    val hiltRule by lazy { HiltAndroidRule(this) }

    @get:Rule(order = 1)
    val test by lazy { createAndroidComposeRule<TestActivity>() }

    @Test
    fun shouldRenderScreen() {
        test.setContent { MyScreen() }
        ...
    }
}

When I run this test the application will crash on savedStateHandle.require<Long>("myId") as myId doesn't exist. I have not been able to find a way to access or override the SavedStateHandle from the test code.

In the application MyScreen is wrapped in a NavHost, which enables me to access the nav arguments in the SavedStateHandle:

@Composable
fun MyApp(
    navController: NavHostController = rememberNavController(),
    startDestination: String = "home",
) {
    ...
    MyTheme {
        NavHost(
            navController = navController,
            startDestination = startDestination,
        ) {
            ...
            composable(
               "route/{myId}", arguments = listOf(
                    navArgument("myId") { type = NavType.LongType }
                )
            ) {
                MyScreen()
            }
            ...
        }
    }
}

Now I realise that when setting the content in the test I can instantiate my own ViewModel with a SavedStateHandle and pass that into the screen, however I have a lot of dependencies that are provided by Hilt so that will require a lot of boilerplate.

I've tried using @BindValue to provide a SavedStateHandle in the test but this results in a duplicate binding error. I've tried @Injecting the SavedStateHandle in the test but the dependency isn't found.

Lastly, I've abandoned the design of testing a single screen composable, instead setting the test content to MyApp passing startDestination="route/1". This is less ideal as there's other code in this composable that I do not want to test. However, this surprisingly had the same issue of the argument not found in the SavedStateHandle in the ViewModel.

So is there an easy way to populate the Hilt-provided SavedStateHandle with nav arguments in the test?


Solution

  • SavedStateHandle populates its arguments from the arguments of the ViewModelStoreOwner that the ViewModel is associated with - when inside your NavHost, that is the individual destination (that's why a route with "route/{myId}" fills in the myId argument - it would not work if your route is "route/1" since that does not include any argument).

    When you don't have a NavHost in your test, the arguments it is pulling from is from the Activity itself - through the extras on the Activity's intent.

    Therefore you can set the arguments on the Activity before you call setContent so that they are available when hiltViewModel() is called:

    @Test
    fun shouldRenderScreen() {
        // Set the extra manually on the Activity's Intent
        test.activity.intent.putExtra("myInt", 0L)
    
        test.setContent { MyScreen() }
        ...
    }