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 @Inject
ing 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?
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() }
...
}