第3章:依赖注入与测试替身:使用Dagger/Hilt或Koin在测试中注入Mock、Fake、Stub对象
3.1 原理:为什么依赖注入是ViewModel测试的前提
ViewModel 通常依赖 Repository、UseCase 或数据源接口。在单元测试中,我们无法(也不应该)实例化真实的网络客户端或数据库。依赖注入(DI)框架的核心价值在于:将依赖对象的构造责任从 ViewModel 中剥离,使得在测试阶段可以无缝替换为 Mock、Fake 或 Stub 对象,从而实现完全可控的隔离测试。
测试替身(Test Double)有三种主要形态:
- Mock:验证行为交互(是否调用了某方法、参数是否正确);
- Fake:轻量级的功能实现(如内存数据库代替真实数据库);
- Stub:返回预设数据,不验证交互。
原则:测试替身只替换 ViewModel 的直接依赖。避免对间接依赖进行 Mock,否则测试将变得脆弱且难以维护。
3.2 实现流程:三步完成 DI 测试替身的装配
| 步骤 | 操作 | DI 框架中的关键注解/API |
|---|---|---|
| 1 | 在测试模块中声明替换规则 | Hilt: @TestInstallIn, @BindValue;Koin: loadKoinModules, mockModule |
| 2 | 在测试类中实例化测试替身 | Mockito: mock();手动实现 Fake 类 |
| 3 | 将替身注入 ViewModel 构造器 | Hilt: @BindValue 字段;Koin: get() 重载 |
3.3 代码示例:Hilt 测试模块与 Mock 注入
// AppModule.kt — 生产环境的依赖定义
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
fun provideRepository(api: ApiService): UserRepository {
return UserRepositoryImpl(api)
}
}
// TestAppModule.kt — 测试模块,替换真实Repository
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [AppModule::class]
)
object TestAppModule {
@Provides
fun provideFakeRepository(): UserRepository {
return FakeUserRepository() // 内存实现
}
}
// ViewModelTest.kt — 使用 @BindValue 注入 Mock
@HiltAndroidTest
class UserViewModelTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@BindValue
val mockRepository: UserRepository = mock()
private lateinit var viewModel: UserViewModel
@Before
fun setup() {
hiltRule.inject()
viewModel = UserViewModel(mockRepository)
}
@Test
fun `loadUser_返回成功_callsRepositoryOnce`() {
`when`(mockRepository.getUser(1)).thenReturn(Result.success(userA))
viewModel.loadUser(1)
verify(mockRepository, times(1)).getUser(1)
}
}
3.4 代码示例:Koin 模块热替换与 Stub 注入
// ProductionModule.kt
val productionModule = module {
single<UserRepository> { UserRepositoryImpl(get()) }
}
// ViewModelTest.kt
class UserViewModelKoinTest : KoinTest {
private val stubRepository = object : UserRepository {
override fun getUser(id: Int): Result<User> = Result.success(stubUser)
}
@Before
fun setup() {
loadKoinModules(
module {
single<UserRepository> { stubRepository }
}
)
}
@After
fun tearDown() {
stopKoin()
}
@Test
fun `loadUser_返回stub数据_刷新状态】`() {
val viewModel = UserViewModel(get()) // get() 自动拿到 stubRepository
viewModel.loadUser(1)
// 断言状态机结果
}
}
3.5 内联SVG:依赖注入与测试替身结构图
3.6 调试技巧与常见错误
| 常见错误 | 表现 | 解决方案 |
|---|---|---|
| 未正确替换模块 | 测试运行仍引用真实网络请求 | 检查 @TestInstallIn(replaces) 的组件是否匹配;Koin 中确认 loadKoinModules 顺序 |
| Mock 未重置 | 跨测试方法间副作用传递 | 使用 @Before 重新创建 Mock 实例;或调用 Mockito.reset() |
| Fake 状态管理不当 | 数据残留导致断言失败 | Fake 类内实现 reset() 方法,在 @Before 中调用 |
Hilt 测试未调用 hiltRule.inject() | @BindValue 注解字段为 null | 确保 inject() 在 @Before 中执行 |
3.7 性能与稳定性优化
推荐做法:
- 优先使用 Fake 而非 Mock:当依赖逻辑简单(如内存数据库)时,Fake 运行速度更快,且代码意图更明确。
- 单次初始化 DI 容器:为整个测试类只调用一次
loadKoinModules或hiltRule.inject(),避免每次测试方法都重新构造容器。 - 避免深度 Mock 链:如果 ViewModel 的依赖链超过两层,考虑重构或使用 Fake 替代中间层。
- 注解 @UnnecessaryMock 检测:Mockito 的
strictness可以帮助检测未使用的 Mock,保持测试清洁。
// 使用 strictness 检测未使用的 Mock(Mockito 4.x+)
@ExtendWith(MockitoExtension::class)
class UserViewModelTest {
@Mock(strictness = Mock.Strictness.LENIENT)
lateinit var repository: UserRepository
// 测试中若未调用 repository,则 LENIENT 不会报错
// 但 WARN 级别会输出日志帮助清理
}
实践警示:不要在测试中同时使用 Mock 和真实的 DI 实例化同一接口。这会导致容器冲突,产生难以排查的
BindingResolutionException。始终保证一个 DI 域内,每个接口只有一个提供者(Mock 或 Fake 或生产实现)。