第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:依赖注入与测试替身结构图

依赖注入与测试替身结构图 @HiltAndroidTest / KoinTest @BindValue mockRepository 注入声明 Hilt / Koin 容器 TestModule / loadKoinModules 解析替换 Mock / Fake / Stub UserRepository 实现 最终注入到 ViewModel UserViewModel(repository) 测试替身通过 DI 模块替换生产依赖,ViewModel 无需感知测试环境与生产环境的差异

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 或生产实现)。