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这个关键注解。

核心配置步骤:

  1. 在测试类上添加@HiltAndroidTest
  2. 添加@RunWith(AndroidJUnit4::class)(Android测试)
  3. 使用@Inject lateinit var注入依赖
  4. @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 知识体系总览

为了让你更直观地理解这一章的内容,我画了一张图:

Hilt/Dagger 测试依赖注入知识体系 依赖注入测试 @HiltAndroidTest 测试Module Fake依赖注入 测试作用域 HiltAndroidRule @Inject lateinit var @TestInstallIn replaces = [...] FakeUserApi 内存数据库 @Singleton ActivityScenario 核心原则:测试环境尽量简化作用域 用Fake代替真实依赖,用@TestInstallIn替换生产Module

10.7 避坑指南

最后,分享几个我实际项目中踩过的坑:

我曾经犯过的错误:

  • 忘记加@HiltAndroidTest:跑测试时一直报空指针,排查了半天才发现是注解没加。
  • 测试Module和生产Module冲突:两个Module都提供了同一个接口的实现,导致Hilt不知道用哪个。解决方案是用@TestInstallIn(replaces = ...)明确指定替换关系。
  • 作用域不匹配:测试Module里用了@ActivityScoped,但测试方法里没有启动Activity,导致注入失败。后来统一改成@Singleton就解决了。
  • Fake对象状态污染:多个测试方法共享同一个Fake对象,导致测试之间互相影响。解决方案是在@Before中重置Fake对象的状态。

嗯,这一章的内容就到这里。依赖注入在测试中的配置其实不复杂,关键是要理解Hilt的测试机制——@HiltAndroidTest启动测试容器,@TestInstallIn替换生产依赖,Fake对象提供轻量级实现,作用域尽量简化。掌握了这些,你的测试代码会变得干净、可维护。


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