14、依赖注入测试:Hilt测试、测试模块、作用域测试
依赖注入这东西,刚接触时觉得麻烦,用久了就回不去了。尤其是做测试的时候,没有DI框架,你想想看,每次构造一个对象都得手动new一堆依赖,那测试代码写起来比业务代码还长。Hilt作为Android官方推荐的DI方案,和Compose测试配合得相当好。今天我们就聊聊怎么在测试中用好Hilt。
14.1 Hilt测试的基本配置
先说说测试模块怎么搭。Hilt测试的核心思路很简单:测试时替换掉真实依赖,注入我们准备好的假数据或Mock对象。这样就能隔离被测代码,只验证我们关心的逻辑。
我习惯在src/test或src/androidTest下建一个di包,专门放测试用的Hilt模块。举个例子:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
object TestRepositoryModule {
@Provides
@Singleton
fun provideUserRepository(): UserRepository {
return FakeUserRepository()
}
}
这里有个关键注解——@TestInstallIn。它告诉Hilt:测试环境下,用我这个模块替换掉原来的RepositoryModule。这样生产代码里的网络请求、数据库操作,在测试时就全变成了Fake实现。
核心要点:@TestInstallIn 的 replaces 参数必须指定要替换的生产模块。如果写错了模块名,Hilt会直接编译报错,不会偷偷用错依赖。
14.2 测试模块的几种写法
测试模块的写法其实挺灵活的。我总结下来,主要有三种模式:
| 模式 | 适用场景 | 示例 |
|---|---|---|
| Fake对象 | 依赖逻辑简单,需要控制行为 | FakeUserRepository 返回固定数据 |
| Mock对象 | 需要验证交互、设置复杂行为 | mock<ApiService>() + whenever |
| 真实对象+测试配置 | 依赖本身是纯逻辑,无副作用 | RealCalculator 直接注入 |
我个人比较推荐Fake对象。为什么?因为Mock框架虽然强大,但容易写出脆弱的测试——改个方法名就全挂了。Fake对象是真实实现的一个简化版,行为更可控,维护成本也低。
来看一个Fake对象的例子:
class FakeUserRepository : UserRepository {
private val users = mutableListOf(
User(id = "1", name = "测试用户"),
User(id = "2", name = "张三")
)
override suspend fun getUsers(): List<User> = users
override suspend fun getUserById(id: String): User? {
return users.find { it.id == id }
}
// 可以添加测试辅助方法
fun addUser(user: User) {
users.add(user)
}
fun clear() {
users.clear()
}
}
你看,这个Fake类实现了UserRepository接口,但内部只用内存列表存数据。测试时可以随意添加、清除数据,非常灵活。
小技巧:Fake对象里可以加一些测试专用的辅助方法,比如addUser()、clear()。这些方法只在测试模块中暴露,生产代码看不到,很安全。
14.3 作用域测试:别让单例坑了你
作用域是Hilt里最容易踩坑的地方。我记得有一次,测试用例跑得好好的,突然就挂了。查了半天,发现是@Singleton作用域的问题——两个测试用例共享了同一个单例对象,状态互相污染了。
为什么会这样?因为Hilt测试默认使用同一个组件实例。如果你的依赖是@Singleton,那所有测试用例用的都是同一个对象。这会导致:
- 测试A修改了单例的状态,测试B拿到的就是脏数据
- 测试顺序不同,结果可能不同
- Mock的
whenever设置可能被覆盖
解决办法有两个:
方法一:每个测试用例重新创建组件
@HiltAndroidTest
@Config(application = HiltTestApplication::class)
class UserViewModelTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Before
fun setUp() {
hiltRule.inject()
// 每个测试用例前重新注入
}
@Test
fun testGetUsers() {
// ...
}
}
方法二:使用@UninstallModules隔离模块
@UninstallModules(RepositoryModule::class)
@HiltAndroidTest
class UserViewModelTest {
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
object TestModule {
// 每个测试类可以有自己的测试模块
}
}
我个人更推荐方法二。它让每个测试类拥有独立的模块配置,互不干扰。而且@UninstallModules可以精确控制哪些模块被替换,粒度更细。
注意:@UninstallModules 只能用在 @HiltAndroidTest 注解的类上。如果用在普通测试类上,编译时会报错。另外,被卸载的模块必须和测试模块在同一个组件中,否则Hilt会找不到依赖。
14.4 实战:测试一个带依赖的ViewModel
光说不练假把式。我们来看一个完整的测试例子。假设有个UserViewModel,它依赖UserRepository和AnalyticsTracker:
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository,
private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users.asStateFlow()
fun loadUsers() {
viewModelScope.launch {
val result = userRepository.getUsers()
_users.value = result
analyticsTracker.track("users_loaded", result.size)
}
}
}
测试代码这样写:
@UninstallModules(RepositoryModule::class, AnalyticsModule::class)
@HiltAndroidTest
class UserViewModelTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var viewModel: UserViewModel
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class, AnalyticsModule::class]
)
object TestModule {
@Provides
@Singleton
fun provideUserRepository(): UserRepository {
return FakeUserRepository()
}
@Provides
@Singleton
fun provideAnalyticsTracker(): AnalyticsTracker {
return FakeAnalyticsTracker()
}
}
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testLoadUsers() = runTest {
// 准备:FakeUserRepository 默认有2个用户
viewModel.loadUsers()
// 验证
val users = viewModel.users.first()
assertEquals(2, users.size)
assertEquals("测试用户", users[0].name)
}
}
这个测试里,我们同时替换了两个生产模块。Fake对象让我们能精确控制数据,测试结果稳定可靠。而且你发现没有?AnalyticsTracker也被替换成了Fake,这样测试时不会真的上报数据,干净利落。
14.5 作用域测试的进阶技巧
有些场景下,我们需要测试不同作用域的行为。比如一个@ActivityScoped的依赖,在Activity重建后是否还能保持状态?
嗯,这里要注意。Hilt测试框架默认不支持模拟Activity重建。但我们可以用ActivityScenario来模拟:
@HiltAndroidTest
class MainActivityTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Test
fun testActivityScopedDependency() {
val scenario = ActivityScenario.launch(MainActivity::class.java)
scenario.onActivity { activity ->
// 获取Activity作用域内的依赖
val scopedRepo = (activity.application as HiltTestApplication)
.componentManager
.getActivityComponent(activity)
?.getScopedRepository()
assertNotNull(scopedRepo)
}
// 模拟重建
scenario.recreate()
scenario.onActivity { activity ->
// 重建后,作用域内的依赖应该还是同一个实例
val scopedRepoAfter = (activity.application as HiltTestApplication)
.componentManager
.getActivityComponent(activity)
?.getScopedRepository()
assertNotNull(scopedRepoAfter)
// 注意:Activity重建后,ActivityComponent也会重建
// 所以这里拿到的应该是新的实例
}
}
}
我曾经在这个地方栽过跟头。当时以为@ActivityScoped的依赖在重建后还能保持,结果发现Activity重建时,整个ActivityComponent都被销毁重建了。说白了,@ActivityScoped的生命周期就是Activity的生命周期,重建就意味着重新创建。
总结一下作用域测试的要点:
@Singleton:整个测试类共享,注意状态隔离@ActivityScoped:随Activity生命周期,重建后重新创建@ViewModelScoped:随ViewModel生命周期,屏幕旋转后保留@FragmentScoped:随Fragment生命周期,最容易被忽略
14.6 知识体系总览
说了这么多,我们来画一张图,把Hilt测试的核心脉络理清楚:
这张图把Hilt测试的三个核心维度串起来了。左边是测试模块的配置方式,中间是作用域测试的注意事项,右边是实战中的具体应用。三者缺一不可。
最后说一句,Hilt测试写得好不好,关键看你对依赖的理解深不深。别把测试模块写得和生产代码一样复杂,保持Fake对象的简单性,测试才能真正帮你发现问题。
公众号:蓝海资料掘金营,微信deep3321