10、依赖注入与测试:Hilt/Dagger测试配置
依赖注入(DI)在Android开发中已经是标配了。但说实话,很多人写业务代码时用Hilt用得飞起,一到写测试就卡壳——测试环境里那些依赖怎么注入?Fake对象怎么替换?作用域怎么处理?
这一章,我们就专门聊聊测试中的依赖注入。我会结合我自己的踩坑经验,把Hilt和Dagger在测试中的配置、注解、Module、Component这些概念讲清楚。
10.1 为什么测试需要依赖注入?
先问一个问题:你写单元测试时,最头疼的是什么?
我个人觉得,最头疼的就是「依赖管理」。一个ViewModel可能依赖Repository,Repository又依赖DataSource,DataSource又依赖Retrofit...你想想看,如果不用DI,测试一个ViewModel你得手动new出来一堆真实对象,还得处理网络请求、数据库读写——这哪是单元测试,分明是集成测试。
依赖注入在测试中的核心作用就两个:
- 解耦:让被测对象不依赖具体实现,只依赖接口
- 可替换:测试时用Fake或Mock替换真实依赖
说白了,DI让测试变得「干净」。你只需要关注被测类的逻辑,不用操心它的依赖是怎么来的。
10.2 Hilt测试配置:@HiltAndroidTest
Hilt为测试提供了专门的注解——@HiltAndroidTest。这个注解的作用是告诉Hilt:这个测试类需要走Hilt的依赖注入流程。
我记得第一次用Hilt写测试时,直接用了@RunWith(AndroidJUnit4::class),结果跑起来各种空指针。后来才发现,少了@HiltAndroidTest这个关键注解。
核心配置步骤:
- 在测试类上添加
@HiltAndroidTest - 添加
@RunWith(AndroidJUnit4::class)(Android测试) - 使用
@Inject lateinit var注入依赖 - 在
@Before中调用hiltRule.inject()
来看一个完整的例子:
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class LoginViewModelTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Inject
lateinit var loginViewModel: LoginViewModel
@Inject
lateinit var loginRepository: LoginRepository
@Before
fun setUp() {
hiltRule.inject()
}
@Test
fun testLoginSuccess() {
// 使用注入的loginViewModel进行测试
val result = loginViewModel.login("test@example.com", "password123")
assertTrue(result.isSuccess)
}
}
小提示:HiltAndroidRule必须在@Before之前执行,所以要用@get:Rule而不是@Rule。这个顺序搞反了,注入就会失败。
10.3 测试Module与Component
在测试中,我们经常需要替换掉真实的依赖。比如网络请求用MockWebServer,数据库用内存数据库。这时候就需要「测试Module」登场了。
测试Module的写法跟生产Module几乎一样,只是位置不同:
- 生产Module:放在
app/src/main/java/下 - 测试Module:放在
app/src/test/java/或app/src/androidTest/java/下
我习惯在测试目录下建一个di包,专门放测试用的Module。举个例子:
// 测试Module:替换网络模块
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [NetworkModule::class]
)
object TestNetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(MockInterceptor())
.build()
}
@Provides
@Singleton
fun provideApiService(client: OkHttpClient): ApiService {
return Retrofit.Builder()
.baseUrl("http://localhost:8080/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
}
注意:@TestInstallIn是Hilt专门为测试提供的注解,它允许你「替换」掉生产环境中的Module。但要注意,被替换的Module必须用@InstallIn标注,并且Component要一致。
Component在测试中通常不需要额外定义。Hilt会自动为测试生成一个TestSingletonComponent,它继承自生产环境的SingletonComponent。你只需要通过@TestInstallIn指定要替换的Component即可。
10.4 注入Fake依赖
Fake对象是测试中常用的手段。它不同于Mock(Mock是行为验证),Fake是一个轻量级的真实实现,比如内存数据库、假网络服务等。
我在项目中遇到过这样一个场景:用户模块依赖一个UserApi接口,生产环境用Retrofit实现。测试时我不想真的发网络请求,就写了一个FakeUserApi:
// 生产接口
interface UserApi {
suspend fun getUser(id: String): User
suspend fun updateUser(user: User): User
}
// Fake实现
class FakeUserApi : UserApi {
private val users = mutableMapOf<String, User>()
override suspend fun getUser(id: String): User {
return users[id] ?: throw Exception("User not found")
}
override suspend fun updateUser(user: User): User {
users[user.id] = user
return user
}
}
然后在测试Module中提供这个Fake:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [UserModule::class]
)
object TestUserModule {
@Provides
@Singleton
fun provideUserApi(): UserApi {
return FakeUserApi()
}
}
这样,所有依赖UserApi的地方,在测试时都会自动注入FakeUserApi。你想想看,是不是比手动Mock要干净得多?
10.5 测试作用域
作用域是Hilt/Dagger里比较容易让人迷糊的概念。在测试中,作用域的处理方式跟生产环境略有不同。
先回顾一下生产环境的作用域:
| 注解 | 作用域 | 生命周期 |
|---|---|---|
| @Singleton | 全局单例 | Application生命周期 |
| @ActivityScoped | Activity级别 | Activity生命周期 |
| @FragmentScoped | Fragment级别 | Fragment生命周期 |
| @ViewModelScoped | ViewModel级别 | ViewModel生命周期 |
在测试中,作用域的处理原则是:尽量使用@Singleton。为什么?因为测试环境通常没有真正的Activity、Fragment生命周期,用@ActivityScoped反而容易出问题。
我曾经踩过一个坑:测试一个@ActivityScoped的依赖,结果每次测试方法执行完,依赖就被销毁了,下一个测试方法又得重新创建。后来我改成@Singleton,问题就解决了。
测试作用域的最佳实践:
- 测试Module中统一使用@Singleton
- 如果必须测试作用域行为,使用
@ActivityScoped并配合ActivityScenario - 避免在测试中使用
@FragmentScoped,除非你明确知道自己在做什么
来看一个使用ActivityScenario测试作用域的例子:
@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class MainActivityTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@Test
fun testActivityScopedDependency() {
val scenario = ActivityScenario.launch(MainActivity::class.java)
scenario.onActivity { activity ->
// 在这里获取ActivityScoped的依赖
val dependency = (activity.application as MyApplication)
.appContainer
.activityContainer
.someDependency
assertNotNull(dependency)
}
}
}
10.6 知识体系总览
为了让你更直观地理解这一章的内容,我画了一张图:
10.7 避坑指南
最后,分享几个我实际项目中踩过的坑:
我曾经犯过的错误:
- 忘记加@HiltAndroidTest:跑测试时一直报空指针,排查了半天才发现是注解没加。
- 测试Module和生产Module冲突:两个Module都提供了同一个接口的实现,导致Hilt不知道用哪个。解决方案是用
@TestInstallIn(replaces = ...)明确指定替换关系。 - 作用域不匹配:测试Module里用了@ActivityScoped,但测试方法里没有启动Activity,导致注入失败。后来统一改成@Singleton就解决了。
- Fake对象状态污染:多个测试方法共享同一个Fake对象,导致测试之间互相影响。解决方案是在
@Before中重置Fake对象的状态。
嗯,这一章的内容就到这里。依赖注入在测试中的配置其实不复杂,关键是要理解Hilt的测试机制——@HiltAndroidTest启动测试容器,@TestInstallIn替换生产依赖,Fake对象提供轻量级实现,作用域尽量简化。掌握了这些,你的测试代码会变得干净、可维护。
公众号:蓝海资料掘金营,微信deep3321