第26章:Koin测试:KoinTest、checkModules、单元测试与集成测试

测试这件事,说实话在依赖注入框架里经常被忽视。很多人觉得「反正就是个容器,能跑就行」。但我在几个大型项目里吃过亏——模块没配好,启动就崩;Scope 乱用,内存泄漏查了两天。嗯,从那时候起,我就养成了给 Koin 写测试的习惯。

这一章我们专门聊 Koin 的测试体系。包括 KoinTest 怎么用、checkModules 能帮你发现什么坑、以及单元测试和集成测试分别怎么写。说白了,就是让你在写代码的时候就能把 DI 的问题揪出来,而不是等到集成测试甚至线上才暴露。

26.1 KoinTest:测试基类与 DSL

Koin 官方提供了一个测试基类叫 KoinTest。它本质上是一个接口,你可以在任何测试类里混入它。我个人习惯在单元测试里直接继承 KoinTest,然后在 @Before 里启动 Koin。

class MyRepositoryTest : KoinTest {

    private val repository: MyRepository by inject()

    @Before
    fun setUp() {
        startKoin {
            modules(testModule)
        }
    }

    @After
    fun tearDown() {
        stopKoin()
    }

    @Test
    fun `repository should not be null`() {
        assertNotNull(repository)
    }
}

这里有个细节:by inject() 是懒加载的,只有第一次访问时才会去容器里拿实例。如果你在 setUp() 之前就访问了 repository,Koin 还没启动,会直接抛异常。我曾经在重构时把 @Before 的顺序搞错了,排查了半天才发现是这个问题。

小技巧:如果你不想在每个测试类里都写 startKoinstopKoin,可以写一个抽象基类,把启动和清理逻辑封装进去。团队里统一用这个基类,能省不少重复代码。

26.2 checkModules:模块自检利器

checkModules 是 Koin 提供的一个非常实用的工具函数。它会遍历你定义的所有模块,尝试实例化每个定义。说白了,就是帮你检查「所有依赖能不能顺利创建出来」。

我建议每个项目都写一个这样的测试:

class KoinModuleTest : KoinTest {

    @Test
    fun `check all modules`() {
        startKoin {
            modules(allModules)
        }.checkModules()
    }
}

这个测试跑起来很快,但它能发现的问题可不少:

  • 循环依赖:A 依赖 B,B 又依赖 A,Koin 会在检查时直接报错。
  • 缺少定义:某个类声明了 inject(),但模块里根本没注册它。
  • 类型不匹配:你注册的是 Interface,但注入的地方要的是 ConcreteClass
  • Scope 配置错误:Scope 内的定义引用了 Scope 外的依赖,或者反过来。
注意:checkModules 只会检查定义是否能创建,不会检查运行时行为。比如某个 Single 对象在创建时依赖网络请求,那这个测试里网络可能不可用,它依然会报错。所以它更适合检查「纯依赖关系」,不适合检查「带外部依赖的初始化」。

26.3 单元测试:隔离的依赖注入

单元测试的核心原则是「隔离」。你测试一个类,就不应该真的去启动 Retrofit、Room 或者数据库。Koin 的测试框架天然支持这种隔离——你可以在测试里只加载最小化的模块。

举个例子,假设我有一个 LoginUseCase,它依赖 UserRepositoryAnalyticsTracker

class LoginUseCaseTest : KoinTest {

    private val useCase: LoginUseCase by inject()
    private val mockRepo: UserRepository = mock()
    private val mockTracker: AnalyticsTracker = mock()

    @Before
    fun setUp() {
        startKoin {
            modules(
                module {
                    single { mockRepo }
                    single { mockTracker }
                    factory { LoginUseCase(get(), get()) }
                }
            )
        }
    }

    @Test
    fun `login success should track event`() {
        `when`(mockRepo.login(any())).thenReturn(Result.success(User("test")))

        useCase.execute("test", "123")

        verify(mockTracker).track("login_success")
    }
}

你看,这里我只注册了三个定义,而且 UserRepositoryAnalyticsTracker 都是 mock 对象。这样测试跑得飞快,而且完全可控。我在项目中遇到过一种情况:有人把整个 Application 的模块都加载到了单元测试里,结果测试启动要 10 秒,还经常因为网络超时而失败。后来改成最小化模块,测试速度提升了 10 倍。

26.4 集成测试:真实的容器

集成测试就不一样了。它要验证的是「整个 DI 容器能不能正常工作」。说白了,就是把所有模块都加载进来,然后看看能不能拿到真正的实例。

我一般会在集成测试里做这几件事:

  1. 加载完整模块:包括网络、数据库、本地存储等所有模块。
  2. 获取关键入口对象:比如 ViewModelRepositoryUseCase
  3. 验证 Scope 生命周期:比如 Activity Scope 在 Activity 销毁后是否被正确清理。
class KoinIntegrationTest : KoinTest {

    @Before
    fun setUp() {
        startKoin {
            modules(
                networkModule,
                databaseModule,
                repositoryModule,
                useCaseModule,
                viewModelModule
            )
        }
    }

    @Test
    fun `should resolve main view model`() {
        val vm: MainViewModel by inject()
        assertNotNull(vm)
        assertNotNull(vm.userRepository)
    }

    @Test
    fun `should resolve scoped dependencies`() {
        val scope = createScope("test_scope", scopeQualifier)
        val scopedRepo: ScopedRepository by scope.inject()
        assertNotNull(scopedRepo)
        scope.close()
    }
}

这里要注意:集成测试里不要 mock 任何东西。如果某个依赖真的需要外部环境(比如网络),你可以用 Test Double 替换掉那个模块,而不是在测试里 mock 单个对象。我习惯的做法是:准备一套 testModule,里面用内存数据库、MockWebServer 等替代真实实现。

26.5 知识体系总览

下面这张图总结了 Koin 测试的核心脉络:

Koin 测试知识体系 KoinTest 基类 测试 DSL + 生命周期 checkModules 模块自检 + 循环依赖检测 测试策略 单元 vs 集成 startKoin / stopKoin by inject() 懒加载 模块隔离 检查内容 循环依赖 / 缺少定义 类型不匹配 / Scope 错误 单元测试 最小化模块 + Mock 集成测试:完整模块 每个测试类独立启动 CI 中必跑 Test Double 替换外部依赖

26.6 避坑指南

最后分享几个我在项目中踩过的坑:

  • 测试之间互相影响:Koin 的容器是全局的。如果你在测试 A 里启动了 Koin,测试 B 没清理,B 可能会拿到 A 的实例。解决方案:每个测试类都在 @After 里调用 stopKoin(),或者用 KoinTestRule 自动管理。
  • checkModules 误报:如果某个定义依赖 Android Context,而测试环境没有,checkModules 会报错。我曾经因为这个把 checkModules 从 CI 里移除了,后来发现是 Context 没 mock 好。正确的做法是:在测试模块里提供 mock Context。
  • Scope 测试遗漏:很多人只测了 SingleFactory,忘了测 Scope。结果 Scope 内的对象在 Activity 重建后没有被正确清理,导致内存泄漏。建议每个 Scope 都写一个测试,验证 scope.close() 后对象是否被释放。
核心总结:Koin 测试并不复杂,但很容易被忽视。我的建议是:
1. 每个项目至少写一个 checkModules 测试,放在 CI 里。
2. 单元测试用最小化模块 + Mock,保证速度和隔离性。
3. 集成测试加载完整模块,验证容器能否正常工作。
4. 别忘了 Scope 测试,这是最容易出内存泄漏的地方。

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