kingston
kingston

Reputation: 11419

Inject a ViewModel in an Espresso test with Hilt and Jetpack Compose

I'm using Jetpack Compose and Hilt. My Activity looks like this one:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {

    private val viewModel: MyViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            val navController = rememberNavController()
            MainContent(
                navController = navController,
                viewModel = viewModel
            )
        }
    }
}

where MainContent is a Composable

@Composable
fun MainContent(
    navController: NavHostController,
    viewModel: MyViewModel
   
) {
...
}

and MyViewModel is

@HiltViewModel
class MyViewModel @Inject constructor(
    @Named(STATE_FEED) private val _state: MutableStateFlow<Boolean>,
) : ViewModel() {
    val state: Flow<MyViewState> = _state
...
}

so it is a ViewModel with a non-empty constructor. Everything works fine when I run the app. When I run the UI automated test though:

@HiltAndroidTest
class MainActivityKtTest {

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

    @get:Rule(order = 1)
    val composeTestRule by lazy { createComposeRule() }

    private lateinit var navController: TestNavHostController

    private lateinit var context: Context

    private lateinit var appMockWebServer: MockWebServer

    @Before
    fun setUp() {
        hiltTestRule.inject()
        appMockWebServer = MockWebServer()
        appMockWebServer.start(BuildConfig.PORT)
        context = InstrumentationRegistry.getInstrumentation().context
    }

    @After
    fun tearDown() {
        appMockWebServer.shutdown()
    }

    @Test
    fun given_xxx_WHEN_yyy_THEN_zzz() {
        appMockWebServer.enqueueSuccess()

        composeTestRule.setContent {
            val viewModel: MyViewModel = hiltViewModel()
            navController = setTestNavController(context)
            MainContent(
                navController = navController,
                viewModel = viewModel
            )
        }
…
    }

I get an error because Hilt doesn't manage to return an instance of the ViewModel. If I change the ViewModel to be with an empty constructor everything works. Is this:

val viewModel: MyViewModel = hiltViewModel()

the right way to inject the ViewModel?

It looks like the problem might be here:

/**
 * Returns an existing
 * [HiltViewModel](https://dagger.dev/api/latest/dagger/hilt/android/lifecycle/HiltViewModel)
 * -annotated [ViewModel] or creates a new one scoped to the current navigation graph present on
 * the {@link NavController} back stack.
 *
 * If no navigation graph is currently present then the current scope will be used, usually, a
 * fragment or an activity.
 *
 * @sample androidx.hilt.navigation.compose.samples.NavComposable
 * @sample androidx.hilt.navigation.compose.samples.NestedNavComposable
 */
@Composable
inline fun <reified VM : ViewModel> hiltViewModel(
    viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
        "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
    },
    key: String? = null
): VM {
    val factory = createHiltViewModelFactory(viewModelStoreOwner)
    return viewModel(viewModelStoreOwner, key, factory = factory)
}

Returns an existing HiltViewModel or creates a new one scoped to the current navigation graph present on the {@link NavController} back stack. If no navigation graph is currently present then the current scope will be used, usually, a fragment or an activity.

In the UI automated test I'm calling the Composable directly so non of these options are available.

If I use:

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

then the activity is created but this will trigger the call to setContent and I will end up calling it twice when I call it from the @Test method

Upvotes: 4

Views: 1043

Answers (1)

kingston
kingston

Reputation: 11419

The only solution I found was to add a different Activity just for the connected tests to be able to use that one in the rule. This Activity doesn't call the setContent in such a way that the test can do it.

val composeTestRule by lazy { createAndroidComposeRule<MainActivityTest>() }

Upvotes: 1

Related Questions