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 的业务逻辑其实就三点:

  1. 参数校验:userId 不能为空
  2. 空值处理:如果 Repository 返回 null,返回默认游客对象
  3. 异常包装:任何异常都包装成 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 测试 业务逻辑验证 if-else 分支 数据转换规则 异常与边界 空值、负数、临界值 异常类型断言 协程与 Flow runTest + Turbine 发射序列验证 依赖注入 手动注入 mock 避免 DI 框架 核心原则:隔离依赖,验证行为,覆盖边界

这张图把 UseCase 测试分成了四个维度:业务逻辑、异常与边界、协程与 Flow、依赖注入。每个维度都有对应的测试策略和工具。你写测试时,可以对照这张图,看看自己有没有遗漏某个维度。

6.7 总结

UseCase 测试,说白了就是“用代码验证代码”。它不复杂,但需要细心。我给你的建议是:

  • 先列业务规则,再写测试
  • 每个测试只验证一个行为
  • 边界条件和异常路径,一个都不能少
  • 用 mock 隔离依赖,用 Turbine 测试 Flow
  • 永远不要在单元测试里启动 DI 框架

做到这些,你的 UseCase 层就会像铜墙铁壁一样,经得起任何重构和需求变更。

最后说一句:测试不是为了完成任务,而是为了让你睡得安稳。当你把 UseCase 的测试写全了,你会发现,改代码时心里特别踏实。嗯,这就是测试的魅力。


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