依赖注入与Mock:Dagger/Hilt如何配合Mock进行单元测试
说实话,很多刚接触Android单元测试的朋友都会问:"我明明可以手动new一个Mock对象,为什么非要搞个Hilt模块来注入?"
这是一个好问题。我早年也这么干过,结果代码里到处是 Mockito.mock(Foo::class.java),测试一多,维护成本直线上升。
依赖注入 + Mock 的组合拳,说白了就是让测试代码也能用上生产环境的DI框架,只是把真实依赖换成Mock对象。这样做的好处很明显:测试用例写起来像搭积木,替换一个依赖只需要改一个Module,不用改十几个测试文件。
为什么要用依赖注入来配合Mock?
我们先把场景理清楚。假设你的ViewModel依赖一个 UserRepository,而UserRepository又依赖 ApiService 和 LocalDatabase。如果不用DI,测试ViewModel时你需要手动构造一整套依赖链:
// 不用DI的测试写法
val mockApi = mock<ApiService>()
val mockDb = mock<LocalDatabase>()
val repo = UserRepository(mockApi, mockDb)
val viewModel = MyViewModel(repo)
嗯,几个依赖还好说。一旦增加到五六个,每次写测试都要重复这套初始化代码,真的很烦。而且如果 UserRepository 的构造函数变了,你得把所有测试文件改一遍。
这就是我为什么坚持在测试里也用Hilt——把依赖的组装交给框架,测试只需声明「哪些模块用Mock替换」。
核心原则:生产环境用真实实现,测试环境用Mock实现,切换逻辑只写一次,所有测试共享。
Hilt模块中的Mock注入方式
Hilt测试支持两种主力方式:@BindValue 和 测试替换Module。我用一张表来对比它们的使用场景:
| 方式 | 优点 | 缺点 | 我常用的场景 |
|---|---|---|---|
@BindValue 绑定Mock实例 |
一行注解搞定,简单粗暴 | 只能用一个字段,多个同类型依赖会混淆 | 单个依赖替换,比如只有一个Repository |
测试Module + @Module @InstallIn + 覆盖生产Module |
可以批量替换,支持多依赖 | 需要额外写一个Module类 | 大型模块替换,比如网络层、数据库层全部Mock |
我个人习惯优先用 @BindValue,简单直接。但如果你需要Mock的对象有多个相同接口的实现(比如两个Logger),那就必须上测试Module了。
实战:在Hilt测试中注入Mock对象
来看一个完整例子。假设我们的ViewModel长这样:
@HiltViewModel
class LoginViewModel @Inject constructor(
private val loginUseCase: LoginUseCase
) : ViewModel() {
fun login(username: String, password: String) {
viewModelScope.launch {
loginUseCase.execute(username, password)
}
}
}
在测试中,我们需要把 LoginUseCase 替换成Mock。用 @BindValue 的方式是这样写的:
@HiltAndroidTest
@Config(application = HiltTestApplication::class)
class LoginViewModelTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@BindValue
@JvmField
val mockLoginUseCase: LoginUseCase = mock()
@Inject
lateinit var viewModel: LoginViewModel
@Before
fun setup() {
hiltRule.inject()
}
@Test
fun testLoginSuccess() {
`when`(mockLoginUseCase.execute(any(), any()))
.thenReturn(Result.success(Unit))
viewModel.login("test", "123")
verify(mockLoginUseCase).execute("test", "123")
}
}
看到没?@BindValue 让Hilt知道「这个字段的值就是我要注入的Mock实例」,而 @Inject lateinit var viewModel 会从Hilt容器里拿到正确的ViewModel,其中 LoginUseCase 已经被替换成我们的Mock了。
小技巧:如果测试里需要多个Mock,可以写多个 @BindValue 字段。但记住字段名不能重复,且所有字段都要用 @JvmField 注解。
避坑指南
我曾经踩过的坑:
- 忘记调用
hiltRule.inject()— 不加的话所有@Inject字段都是null,这个错我排查了半小时才发现。 - Mock对象用
mock()而不是spy()— 如果你需要保留部分真实逻辑,记得用spy()。但大多数单元测试推荐全Mock。 - 多个测试类共用一个Hilt模块替换时,注意
@UninstallModules— 如果你的测试Module覆盖率所有测试,需要在 @HiltAndroidTest 前加@UninstallModules(ProductionNetworkModule::class)来卸载生产模块。
核心流程:DI + Mock 之间的关系
这个图你看懂了吗?
简单说就是:生产环境走蓝色模块,测试环境走绿色模块,Hilt容器无差别提供依赖,最终注入到ViewModel等目标里。 箭头方向表示「依赖供给方向」——模块告诉容器「我能提供这个接口的实现」,容器再把它送给需要它的对象。
最后说点实在的
依赖注入配合Mock这套思路,我用了快四年。每次看到新同事在测试里手动new依赖链条,我都会推荐他们切换到Hilt测试方案。初期可能觉得「多写了个Module,好麻烦」,但等你测试文件超过20个,就会明白「一次配置,到处复用」有多香。
嗯,这一章就聊到这里。记住:好的测试架构,应当让写测试像写业务代码一样自然。