Jetpack Compose instrument test with @HiltViewModel
Asked Answered
W

3

16

So I want to test my jetpack compose project. It's easy enough running an instrument test following [these instructions]1 on android dev site, but when you add @HiltViewModel injection into the combination things get complicated.

I'm trying to test a pretty simple compose screen with a ViewModel that has an @Inject constructor. The screen itself looks like this:

@Composable
fun LandingScreen() {
    val loginViewModel: LoginViewModel = viewModel()

    MyTheme {
        Surface(color = MaterialTheme.colors.background) {
            val user by loginViewModel.user.observeAsState()
            if (user != null) {
                MainScreen()
            } else {
                LoginScreen(loginViewModel)
            }
        }
    }
}

and this is the view model:

@HiltViewModel
class LoginViewModel @Inject constructor(private val userService: UserService) : ViewModel() {
    val user = userService.loggedInUser.asLiveData()
}

User service is of course backed by a room database and the loggedInUser property returns a Flow.

Things work as expected on standard run but when trying to run it in an instrument test it can't inject the view model.

@HiltAndroidTest
class LandingScreenTest {
    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @get:Rule
    val composeTestRule = createComposeRule()

    @Inject
    lateinit var loginViewModel: LoginViewModel

    @Before
    fun init() {
        hiltRule.inject()
    }

    @Test
    fun MyTest() {
        composeTestRule.setContent {
            MyTheme {
                LandingScreen()
            }
        }

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

Injection of an @HiltViewModel class is prohibited since it does not create a ViewModel instance correctly. Access the ViewModel via the Android APIs (e.g. ViewModelProvider) instead. Injected ViewModel: com.example.viewmodels.LoginViewModel

How do you make that work with the ViewModelProvider instead of the @HiltViewModel?

Whitehouse answered 18/8, 2021 at 5:12 Comment(3)
Did you find any solution?Heywood
@Heywood Unfortunately not. I had to park it for now.Whitehouse
github.com/google/dagger/issues/2318 might give you answersAircool
B
7

Hilt needs an entry point to inject fields. In this case that would probably be an Activity annotated with @AndroidEntryPoint. You can use your MainActivity for that, but that would mean that you would then have to add code to every test to navigate to the desired screen which could be tedious depending on the size of your app, and is not feasible if your project is multimodule and your current Test file does not have access to MainActivity. Instead, you could create a separate dummy Activity whose sole purpose is to host your composable (in this case LoginScreen) and annotate it with @AndroidEntryPoint. Make sure to put it into a debug directory so it's not shipped with the project. Then you can use createAndroidComposeRule<Activity>() to reference that composable. You dont need to inject the ViewModel directly so get rid of that line too.

In the end your Test File should look like this:

@HiltAndroidTest
class LandingScreenTest {
    @get:Rule(order = 0)
    val hiltRule = HiltAndroidRule(this)

    @get:Rule(order = 1)
    val composeRule = createAndroidComposeRule<LoginTestActivity>()

    @Before
    fun init() {
        hiltRule.inject()
    }

    @Test
    fun MyTest() {
        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

And your your dummy activity can look like this:

@AndroidEntryPoint
class LoginTestActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) { 
        super.onCreate(savedInstanceState)
        setContent {
            LoginScreen()
        }
    }
}

And the debug directory would look like this:debug directory with dummy activity

Yes the debug directory has its own manifest and that is where you should add the dummy activity. set exported to false.

Brunhilde answered 8/8, 2022 at 18:20 Comment(1)
This approach actually fits pretty well with the Hilt testing philosophy, It would be really great if instead adding a TestActivity class you actually test the production activity class. dagger.dev/hilt/testing-philosophy.htmlGownsman
M
3

To complete the Tanner Harding's solution, the manifest file in the debug folder should be something like this:

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<application>
    <activity
        android:name=".LoginTestActivity "
        android:exported="false">
    </activity>
</application>
Metatarsus answered 30/11, 2023 at 6:51 Comment(0)
I
2

Try to do something like this:

@HiltAndroidTest
class LandingScreenTest {
    @get:Rule
    var hiltRule = HiltAndroidRule(this)

    @get:Rule
    val composeTestRule = createComposeRule()

    // Remove this line @Inject
    lateinit var loginViewModel: LoginViewModel

    @Before
    fun init() {
        hiltRule.inject()
    }

    @Test
    fun MyTest() {
        composeTestRule.setContent {
            loginViewModel= hiltViewModel() // Add this line
            MyTheme {
                LandingScreen()
            }
        }

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}
Irrespective answered 19/6, 2022 at 23:21 Comment(2)
what is the dependency of hiltViewModel()? or Is it a custom function?Propylite
import androidx.hilt.navigation.compose.hiltViewModelIrrespective

© 2022 - 2025 — McMap. All rights reserved.