第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 的顺序搞错了,排查了半天才发现是这个问题。
startKoin 和 stopKoin,可以写一个抽象基类,把启动和清理逻辑封装进去。团队里统一用这个基类,能省不少重复代码。
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,它依赖 UserRepository 和 AnalyticsTracker:
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")
}
}
你看,这里我只注册了三个定义,而且 UserRepository 和 AnalyticsTracker 都是 mock 对象。这样测试跑得飞快,而且完全可控。我在项目中遇到过一种情况:有人把整个 Application 的模块都加载到了单元测试里,结果测试启动要 10 秒,还经常因为网络超时而失败。后来改成最小化模块,测试速度提升了 10 倍。
26.4 集成测试:真实的容器
集成测试就不一样了。它要验证的是「整个 DI 容器能不能正常工作」。说白了,就是把所有模块都加载进来,然后看看能不能拿到真正的实例。
我一般会在集成测试里做这几件事:
- 加载完整模块:包括网络、数据库、本地存储等所有模块。
- 获取关键入口对象:比如
ViewModel、Repository、UseCase。 - 验证 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 测试的核心脉络:
26.6 避坑指南
最后分享几个我在项目中踩过的坑:
- 测试之间互相影响:Koin 的容器是全局的。如果你在测试 A 里启动了 Koin,测试 B 没清理,B 可能会拿到 A 的实例。解决方案:每个测试类都在
@After里调用stopKoin(),或者用KoinTestRule自动管理。 - checkModules 误报:如果某个定义依赖 Android Context,而测试环境没有,
checkModules会报错。我曾经因为这个把checkModules从 CI 里移除了,后来发现是 Context 没 mock 好。正确的做法是:在测试模块里提供 mock Context。 - Scope 测试遗漏:很多人只测了
Single和Factory,忘了测 Scope。结果 Scope 内的对象在 Activity 重建后没有被正确清理,导致内存泄漏。建议每个 Scope 都写一个测试,验证scope.close()后对象是否被释放。
1. 每个项目至少写一个
checkModules 测试,放在 CI 里。2. 单元测试用最小化模块 + Mock,保证速度和隔离性。
3. 集成测试加载完整模块,验证容器能否正常工作。
4. 别忘了 Scope 测试,这是最容易出内存泄漏的地方。
公众号:蓝海资料掘金营,微信deep3321