如何在Jetpack Compose+Hilt的Espresso测试中正确注入ViewModel?
我正在使用Jetpack Compose和Hilt框架,MainActivity代码如下:
@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 ) } } }
其中MainContent是一个Composable组件:
@Composable fun MainContent( navController: NavHostController, viewModel: MyViewModel ) { ... }
MyViewModel的定义如下:
@HiltViewModel class MyViewModel @Inject constructor( @Named(STATE_FEED) private val _state: MutableStateFlow<Boolean>, ) : ViewModel() { val state: Flow<MyViewState> = _state ... }
这是一个带有非空构造方法的ViewModel,正常运行应用时一切正常。但在运行UI自动化测试时:
@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 ) } … }
我遇到错误,Hilt无法返回ViewModel实例,将ViewModel改为空构造方法则正常运行。请问使用val viewModel: MyViewModel = hiltViewModel()是否是正确的注入方式?
根据hiltViewModel()的源码注释:
/** * 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) }
它会返回已有的或创建新的HiltViewModel,作用域为NavController返回栈的当前导航图;若无导航图则使用当前作用域(通常是Fragment或Activity)。
但测试中直接调用Composable组件,上述两种作用域都不存在。若使用createAndroidComposeRule<MainActivity>(),会创建Activity并触发setContent,导致测试中重复调用setContent。请问如何正确在测试中注入ViewModel?
1. 理解hiltViewModel()失败的原因
hiltViewModel()本身是正确的注入方式,但它依赖LocalViewModelStoreOwner.current提供作用域。纯Compose测试(用createComposeRule())默认没有关联的ViewModelStoreOwner(比如Activity、Fragment或导航图),所以Hilt无法找到合适的作用域来创建带构造参数的ViewModel实例。
2. 三种可行的测试注入方案
方案一:直接在测试类中注入ViewModel实例
测试类已经标注@HiltAndroidTest,可以直接通过Hilt注入ViewModel,再传递给Composable组件,完全绕开Compose中的作用域问题:
@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 appMockWebServer: MockWebServer // 直接用Hilt注入ViewModel实例 @Inject lateinit var viewModel: MyViewModel @Before fun setUp() { hiltTestRule.inject() appMockWebServer = MockWebServer() appMockWebServer.start(BuildConfig.PORT) } @After fun tearDown() { appMockWebServer.shutdown() } @Test fun given_xxx_WHEN_yyy_THEN_zzz() { appMockWebServer.enqueueSuccess() composeTestRule.setContent { navController = setTestNavController(InstrumentationRegistry.getInstrumentation().context) // 直接传入已注入的ViewModel MainContent( navController = navController, viewModel = viewModel ) } // 执行测试断言逻辑 ... } }
方案二:使用createAndroidComposeRule<MainActivity>()关联Activity
用createAndroidComposeRule<MainActivity>()启动Activity后,替换其setContent逻辑,此时Activity作为ViewModelStoreOwner,Hilt可以正常创建ViewModel:
@HiltAndroidTest class MainActivityKtTest { @get:Rule(order = 0) val hiltTestRule by lazy { HiltAndroidRule(this) } // 关联MainActivity,获取Activity实例控制权 @get:Rule(order = 1) val composeTestRule by lazy { createAndroidComposeRule<MainActivity>() } private lateinit var navController: TestNavHostController private lateinit var appMockWebServer: MockWebServer @Before fun setUp() { hiltTestRule.inject() appMockWebServer = MockWebServer() appMockWebServer.start(BuildConfig.PORT) } @After fun tearDown() { appMockWebServer.shutdown() } @Test fun given_xxx_WHEN_yyy_THEN_zzz() { appMockWebServer.enqueueSuccess() // 替换Activity的setContent,避免重复调用 composeTestRule.activity.setContent { navController = setTestNavController(composeTestRule.activity) // 此时有Activity作为作用域,hiltViewModel()可正常工作 val viewModel: MyViewModel = hiltViewModel() MainContent( navController = navController, viewModel = viewModel ) } // 执行测试断言逻辑 ... } }
方案三:手动提供ViewModelStoreOwner上下文
如果不想启动完整Activity,可以手动创建一个测试用的ViewModelStoreOwner,并通过CompositionLocalProvider注入Compose上下文:
@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 appMockWebServer: MockWebServer // 创建测试用的ViewModelStoreOwner private val testViewModelStoreOwner = object : ViewModelStoreOwner { private val store = ViewModelStore() override fun getViewModelStore(): ViewModelStore = store } @Before fun setUp() { hiltTestRule.inject() appMockWebServer = MockWebServer() appMockWebServer.start(BuildConfig.PORT) } @After fun tearDown() { appMockWebServer.shutdown() // 清理ViewModelStore,避免内存泄漏 testViewModelStoreOwner.viewModelStore.clear() } @Test fun given_xxx_WHEN_yyy_THEN_zzz() { appMockWebServer.enqueueSuccess() composeTestRule.setContent { // 手动提供ViewModelStoreOwner上下文 CompositionLocalProvider(LocalViewModelStoreOwner provides testViewModelStoreOwner) { navController = setTestNavController(InstrumentationRegistry.getInstrumentation().context) val viewModel: MyViewModel = hiltViewModel() MainContent( navController = navController, viewModel = viewModel ) } } // 执行测试断言逻辑 ... } }
3. 总结
优先选择方案一(直接注入ViewModel实例),这种方式最简洁,完全符合Hilt的测试设计;如果需要模拟完整的Activity场景,再选择方案二;方案三适合特殊场景下的轻量测试,但需要注意手动清理资源。
内容的提问来源于stack exchange,提问作者kingston

