6、UseCase/Interactor单元测试:Clean Architecture中的UseCase测试、测试业务逻辑、测试异常处理与边界条件、测试协程与Flow、测试依赖注入
好,咱们今天聊聊 UseCase 的单元测试。
说实话,在 Clean Architecture 里,UseCase 层是我个人最看重的一层。为什么?因为它是业务逻辑的“心脏”。你想想看,ViewModel 只是数据的搬运工,Repository 只是数据的仓库管理员,真正决定“这个功能该怎么运作”的,是 UseCase。所以,它的测试质量,直接决定了你的 App 靠不靠谱。
核心观点:UseCase 测试的本质,是验证“给定输入 + 依赖行为 → 期望输出”这条链路是否通畅。它不关心数据从哪来,只关心业务规则是否被正确执行。
6.1 为什么 UseCase 测试如此重要?
我记得刚带团队那会儿,有个同事跑来问我:“哥,UseCase 里就几行代码,调一下 Repository 的方法,有必要单独写测试吗?”
我当时没直接回答,而是让他去修一个线上 bug。那个 bug 是这样的:用户下单时,如果商品库存刚好为 0,系统应该弹出一个“库存不足”的提示。但实际表现是,库存为 0 时,App 直接崩溃了。
他查了半天,发现是 UseCase 里少了一个边界条件判断——当库存等于 0 时,没有返回错误结果,而是继续执行了下游操作。你看,这就是典型的业务逻辑漏洞。如果没有单元测试,这种问题很难在开发阶段暴露。
所以,UseCase 测试的核心价值在于:
- 验证业务规则:确保 if-else、分支判断、数据转换逻辑正确
- 覆盖异常路径:网络错误、空数据、非法参数等场景
- 保护重构安全:当你修改内部实现时,测试能告诉你有没有破坏原有行为
- 文档化行为:测试用例本身就是一份可执行的业务文档
6.2 测试业务逻辑:从最简单的例子开始
咱们先看一个典型的 UseCase 长什么样。假设我们有一个 GetUserProfileUseCase,它的职责是:根据用户 ID 获取用户信息,如果用户不存在,返回一个默认的“游客”对象。
class GetUserProfileUseCase(
private val userRepository: UserRepository
) {
suspend operator fun invoke(userId: String): Result<UserProfile> {
return try {
if (userId.isBlank()) {
return Result.failure(IllegalArgumentException("userId 不能为空"))
}
val profile = userRepository.getUserProfile(userId)
if (profile == null) {
Result.success(UserProfile.defaultGuest())
} else {
Result.success(profile)
}
} catch (e: Exception) {
Result.failure(e)
}
}
}
这个 UseCase 的业务逻辑其实就三点:
- 参数校验:userId 不能为空
- 空值处理:如果 Repository 返回 null,返回默认游客对象
- 异常包装:任何异常都包装成 Result.failure
对应的测试该怎么写?我习惯用 mockk 来模拟依赖,用 kotlinx.coroutines.test 来管理协程。
class GetUserProfileUseCaseTest {
private val userRepository: UserRepository = mockk()
private lateinit var useCase: GetUserProfileUseCase
@BeforeEach
fun setup() {
useCase = GetUserProfileUseCase(userRepository)
}
@Test
fun `当userId为空时,返回IllegalArgumentException`() = runTest {
val result = useCase.invoke("")
assertTrue(result.isFailure)
assertEquals(IllegalArgumentException::class, result.exceptionOrNull()!!::class)
}
@Test
fun `当用户不存在时,返回默认游客对象`() = runTest {
coEvery { userRepository.getUserProfile("non_exist_id") } returns null
val result = useCase.invoke("non_exist_id")
assertTrue(result.isSuccess)
assertEquals(UserProfile.defaultGuest(), result.getOrNull())
}
@Test
fun `当Repository抛出异常时,返回failure`() = runTest {
val exception = RuntimeException("网络错误")
coEvery { userRepository.getUserProfile("any_id") } throws exception
val result = useCase.invoke("any_id")
assertTrue(result.isFailure)
assertEquals(exception, result.exceptionOrNull())
}
}
你看,每个测试用例只验证一个行为。这样当测试失败时,你能立刻定位到是哪个业务规则出了问题。
个人经验:写 UseCase 测试时,我习惯先列出所有“业务规则”,然后为每条规则写一个测试。比如上面的例子,规则就是:空参数→失败、空数据→默认值、异常→包装。这样写出来的测试,覆盖率又高又清晰。
6.3 测试异常处理与边界条件
异常处理和边界条件,是很多开发者容易忽略的地方。我曾经接手过一个项目,UseCase 里有一个 calculateDiscount 方法,逻辑是:如果订单金额大于 1000 元,打 9 折;否则不打折。
看起来很简单对吧?但线上出了个问题:当订单金额刚好等于 1000 元时,用户发现没有打折。产品经理说“大于等于 1000 元应该打折”,但代码写的是 > 1000。这就是边界条件没测透。
所以,我建议你在测试中重点关注这些边界:
- 数值边界:最小值、最大值、等于临界值
- 空值/空集合:null、空字符串、空列表
- 特殊输入:负数、零、超长字符串
- 并发边界:同时调用多次、超时场景
来看一个更复杂的例子:一个 SearchProductsUseCase,它需要处理分页和搜索关键词。
class SearchProductsUseCase(
private val productRepository: ProductRepository
) {
suspend operator fun invoke(
query: String,
page: Int,
pageSize: Int
): Result<List<Product>> {
return try {
if (query.isBlank()) {
return Result.failure(IllegalArgumentException("搜索关键词不能为空"))
}
if (page < 1 || pageSize < 1) {
return Result.failure(IllegalArgumentException("分页参数必须大于0"))
}
val products = productRepository.searchProducts(query, page, pageSize)
Result.success(products)
} catch (e: Exception) {
Result.failure(e)
}
}
}
边界条件测试可以这样写:
@Test
fun `当page为0时,返回IllegalArgumentException`() = runTest {
val result = useCase.invoke("手机", page = 0, pageSize = 20)
assertTrue(result.isFailure)
}
@Test
fun `当pageSize为负数时,返回IllegalArgumentException`() = runTest {
val result = useCase.invoke("手机", page = 1, pageSize = -1)
assertTrue(result.isFailure)
}
@Test
fun `当搜索关键词为空白时,返回IllegalArgumentException`() = runTest {
val result = useCase.invoke(" ", page = 1, pageSize = 20)
assertTrue(result.isFailure)
}
@Test
fun `当Repository返回空列表时,返回空列表`() = runTest {
coEvery { productRepository.searchProducts(any(), any(), any()) } returns emptyList()
val result = useCase.invoke("不存在的商品", page = 1, pageSize = 20)
assertTrue(result.isSuccess)
assertEquals(emptyList<Product>(), result.getOrNull())
}
避坑指南:我曾经犯过一个错误——在测试异常时,只测了“抛出异常”这个场景,但没有验证“异常类型是否正确”。比如 Repository 抛出一个 IOException,但 UseCase 却把它包装成了 RuntimeException。这样上层 ViewModel 就无法根据异常类型做差异化处理。所以,一定要断言异常的具体类型。
6.4 测试协程与Flow
现在很多 UseCase 都返回 Flow,特别是涉及到实时数据更新的场景。比如一个 ObserveUnreadCountUseCase,它会监听未读消息数量的变化。
class ObserveUnreadCountUseCase(
private val messageRepository: MessageRepository
) {
operator fun invoke(): Flow<Int> {
return messageRepository.observeUnreadCount()
.map { count ->
if (count < 0) {
throw IllegalStateException("未读消息数不能为负数")
}
count
}
.catch { e ->
emit(-1) // 发生异常时,发射一个特殊值
}
}
}
测试 Flow 时,我推荐使用 Turbine 这个库,它让 Flow 测试变得非常直观。
class ObserveUnreadCountUseCaseTest {
private val messageRepository: MessageRepository = mockk()
private lateinit var useCase: ObserveUnreadCountUseCase
@BeforeEach
fun setup() {
useCase = ObserveUnreadCountUseCase(messageRepository)
}
@Test
fun `正常场景下,发射未读消息数`() = runTest {
val flow = flowOf(5, 3, 0)
every { messageRepository.observeUnreadCount() } returns flow
useCase().test {
assertEquals(5, awaitItem())
assertEquals(3, awaitItem())
assertEquals(0, awaitItem())
awaitComplete()
}
}
@Test
fun `当未读消息数为负数时,发射-1`() = runTest {
val flow = flowOf(5, -1, 3)
every { messageRepository.observeUnreadCount() } returns flow
useCase().test {
assertEquals(5, awaitItem())
assertEquals(-1, awaitItem()) // 异常被捕获,发射-1
assertEquals(3, awaitItem())
awaitComplete()
}
}
@Test
fun `当Flow完成时,测试也完成`() = runTest {
val flow = flowOf(10)
every { messageRepository.observeUnreadCount() } returns flow
useCase().test {
assertEquals(10, awaitItem())
awaitComplete()
}
}
}
这里有个细节要注意:test 函数是 Turbine 提供的扩展方法,它会自动收集 Flow 并让你逐个断言发射的值。如果你用 first() 或 toList(),可能会错过中间值或异常。
我的习惯:测试 Flow 时,我会先画一个“发射序列图”,把每个时间点应该发射什么值写下来。然后对照着写测试。这样不容易漏掉中间状态。
6.5 测试依赖注入
说到依赖注入,很多同学会问:“UseCase 测试时,要不要用 DI 框架?”
我的答案是:不要。单元测试的核心是“隔离”,你应该手动创建 UseCase 实例,并传入 mock 的依赖。这样测试才快、才可控。
但有一种情况例外:如果你的 UseCase 内部使用了 ServiceLocator 或全局单例,那测试起来就很痛苦。比如:
class BadUseCase {
suspend fun execute() {
val repo = ServiceLocator.getRepository() // 糟糕的设计
// ...
}
}
这种代码几乎没法单元测试,因为你无法替换 ServiceLocator 里的实现。正确的做法是:
class GoodUseCase(
private val repository: Repository // 通过构造函数注入
) {
suspend fun execute() {
// ...
}
}
在测试中,你只需要:
class GoodUseCaseTest {
private val repository: Repository = mockk()
private lateinit var useCase: GoodUseCase
@BeforeEach
fun setup() {
useCase = GoodUseCase(repository) // 手动注入 mock
}
// ... 测试用例
}
如果你用了 Hilt 或 Koin,也完全不需要在测试中启动它们。直接 new 对象,传 mock 进去就行。这样测试跑起来飞快,一秒跑几百个用例不是问题。
避坑指南:我曾经见过一个项目,测试里用了 hiltTest 注解,每个测试都要启动 Hilt 组件,跑一个测试要 5 秒。整个测试套件跑下来要 20 分钟。后来我改成手动注入,测试时间缩短到 30 秒。所以,单元测试里别用 DI 框架,那是集成测试该干的事。
6.6 知识体系总览
为了让你更直观地理解 UseCase 测试的全貌,我画了一张图:
这张图把 UseCase 测试分成了四个维度:业务逻辑、异常与边界、协程与 Flow、依赖注入。每个维度都有对应的测试策略和工具。你写测试时,可以对照这张图,看看自己有没有遗漏某个维度。
6.7 总结
UseCase 测试,说白了就是“用代码验证代码”。它不复杂,但需要细心。我给你的建议是:
- 先列业务规则,再写测试
- 每个测试只验证一个行为
- 边界条件和异常路径,一个都不能少
- 用 mock 隔离依赖,用 Turbine 测试 Flow
- 永远不要在单元测试里启动 DI 框架
做到这些,你的 UseCase 层就会像铜墙铁壁一样,经得起任何重构和需求变更。
最后说一句:测试不是为了完成任务,而是为了让你睡得安稳。当你把 UseCase 的测试写全了,你会发现,改代码时心里特别踏实。嗯,这就是测试的魅力。
公众号:蓝海资料掘金营,微信deep3321