14、依赖注入测试:Hilt测试、测试模块、作用域测试

依赖注入这东西,刚接触时觉得麻烦,用久了就回不去了。尤其是做测试的时候,没有DI框架,你想想看,每次构造一个对象都得手动new一堆依赖,那测试代码写起来比业务代码还长。Hilt作为Android官方推荐的DI方案,和Compose测试配合得相当好。今天我们就聊聊怎么在测试中用好Hilt。

14.1 Hilt测试的基本配置

先说说测试模块怎么搭。Hilt测试的核心思路很简单:测试时替换掉真实依赖,注入我们准备好的假数据或Mock对象。这样就能隔离被测代码,只验证我们关心的逻辑。

我习惯在src/testsrc/androidTest下建一个di包,专门放测试用的Hilt模块。举个例子:

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [RepositoryModule::class]
)
object TestRepositoryModule {

    @Provides
    @Singleton
    fun provideUserRepository(): UserRepository {
        return FakeUserRepository()
    }
}

这里有个关键注解——@TestInstallIn。它告诉Hilt:测试环境下,用我这个模块替换掉原来的RepositoryModule。这样生产代码里的网络请求、数据库操作,在测试时就全变成了Fake实现。

核心要点@TestInstallInreplaces 参数必须指定要替换的生产模块。如果写错了模块名,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,它依赖UserRepositoryAnalyticsTracker

@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测试 测试模块配置 作用域测试 实战应用 @TestInstallIn + replaces Fake对象 / Mock对象 @UninstallModules 隔离 @Singleton 状态隔离 @ActivityScoped 重建测试 @ViewModelScoped 旋转保留 ViewModel测试 ActivityScenario模拟 Fake辅助方法 核心:隔离依赖 → 控制行为 → 验证结果

这张图把Hilt测试的三个核心维度串起来了。左边是测试模块的配置方式,中间是作用域测试的注意事项,右边是实战中的具体应用。三者缺一不可。

最后说一句,Hilt测试写得好不好,关键看你对依赖的理解深不深。别把测试模块写得和生产代码一样复杂,保持Fake对象的简单性,测试才能真正帮你发现问题。


公众号:蓝海资料掘金营,微信deep3321