依赖注入与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 DI + Mock 测试流程 生产 Module 测试 Module / @BindValue Hilt 容器(DI) 依赖接口(如 LoginUseCase) 真实实现(生产) Mock 实现(测试) ViewModel / UseCase @Inject 注入 单元测试用例 验证行为 ⚠️ 关键机制 测试时 @UninstallModules(生产Module) 然后安装测试Module(或使用@BindValue)

这个图你看懂了吗?
简单说就是:生产环境走蓝色模块,测试环境走绿色模块,Hilt容器无差别提供依赖,最终注入到ViewModel等目标里。 箭头方向表示「依赖供给方向」——模块告诉容器「我能提供这个接口的实现」,容器再把它送给需要它的对象。

最后说点实在的

依赖注入配合Mock这套思路,我用了快四年。每次看到新同事在测试里手动new依赖链条,我都会推荐他们切换到Hilt测试方案。初期可能觉得「多写了个Module,好麻烦」,但等你测试文件超过20个,就会明白「一次配置,到处复用」有多香。

嗯,这一章就聊到这里。记住:好的测试架构,应当让写测试像写业务代码一样自然。