29、UseCase单元测试:业务规则测试、边界条件测试、异常路径测试

说实话,很多团队写单元测试,最后都变成了「为了覆盖率而写测试」。我见过不少项目,覆盖率报表漂漂亮亮,但核心业务逻辑一改,测试全绿通过,线上却崩了。为什么?因为测试没打在点上。

今天我们就聊聊 UseCase 层的单元测试。这一层是业务逻辑的核心,也是我最看重的一层。我个人习惯把测试分成三类:业务规则测试边界条件测试异常路径测试。这三板斧下去,基本能把 UseCase 的坑填平。

核心观点:UseCase 测试不是为了测框架,而是为了测「当输入 X 时,业务规则是否按预期执行」。说白了,就是验证你的 if-else、状态机、数据转换逻辑对不对。

UseCase 单元测试 业务规则测试 状态流转验证 数据聚合/计算 权限/角色判断 边界条件测试 空值/零值/空集合 最大/最小阈值 特殊字符/超长字符串 异常路径测试 网络/IO 异常 数据格式错误 依赖服务超时

一、业务规则测试:验证「逻辑对不对」

业务规则测试,是 UseCase 测试的基石。它的目标很简单:给定一组输入,验证输出是否符合业务预期

举个例子,假设我们有一个 CalculateOrderTotalUseCase,它要根据商品价格、数量、折扣规则计算订单总价。业务规则可能是:满 200 减 30,VIP 用户额外打 9 折。

class CalculateOrderTotalUseCase(
    private val userRepository: UserRepository,
    private val discountPolicy: DiscountPolicy
) {
    suspend operator fun invoke(items: List<OrderItem>, userId: String): Result<Double> {
        // 业务规则1:计算原始总价
        val subtotal = items.sumOf { it.price * it.quantity }
        
        // 业务规则2:满减规则
        val afterPromotion = discountPolicy.applyPromotion(subtotal)
        
        // 业务规则3:VIP 折扣
        val user = userRepository.getUser(userId) ?: return Result.failure(UserNotFound())
        val finalTotal = if (user.isVip) afterPromotion * 0.9 else afterPromotion
        
        return Result.success(finalTotal)
    }
}

测试怎么写?我习惯把业务规则拆成独立的测试用例:

class CalculateOrderTotalUseCaseTest {

    private val userRepository = mock<UserRepository>()
    private val discountPolicy = mock<DiscountPolicy>()
    private val useCase = CalculateOrderTotalUseCase(userRepository, discountPolicy)

    @Test
    fun `普通用户满200减30后总价正确`() = runTest {
        // 准备
        val items = listOf(OrderItem(price = 100.0, quantity = 3)) // 原价300
        val user = User(id = "1", isVip = false)
        whenever(userRepository.getUser("1")).thenReturn(user)
        whenever(discountPolicy.applyPromotion(300.0)).thenReturn(270.0) // 满减后270

        // 执行
        val result = useCase(items, "1")

        // 验证
        assertTrue(result.isSuccess)
        assertEquals(270.0, result.getOrNull(), 0.001)
    }

    @Test
    fun `VIP用户满200减30后再打9折`() = runTest {
        // 准备
        val items = listOf(OrderItem(price = 100.0, quantity = 3))
        val user = User(id = "2", isVip = true)
        whenever(userRepository.getUser("2")).thenReturn(user)
        whenever(discountPolicy.applyPromotion(300.0)).thenReturn(270.0)

        // 执行
        val result = useCase(items, "2")

        // 验证
        assertTrue(result.isSuccess)
        assertEquals(243.0, result.getOrNull(), 0.001) // 270 * 0.9 = 243
    }
}

我的经验:业务规则测试最容易犯的错误是「只测 happy path」。我曾经接手过一个支付模块,满减规则写了 5 层 if-else,但测试只覆盖了最外层。结果改了一行代码,内层规则全崩了。所以我的建议是:每个 if 分支都要有对应的测试用例,哪怕它看起来很简单。

二、边界条件测试:验证「极端值会不会崩」

边界条件测试,说白了就是找茬。你想想看,用户输入的数据千奇百怪,空列表、负数、超大数值……你的 UseCase 扛得住吗?

我个人习惯在写业务逻辑时,就同步想好边界条件。比如上面的订单计算,我会问自己几个问题:

  • 如果 items 是空列表,应该返回什么?
  • 如果商品数量是 0 或负数,怎么处理?
  • 如果价格是 0 元(比如赠品),折扣规则还适用吗?
  • 如果 userId 不存在,是抛异常还是返回默认值?

来看一个边界条件的测试示例:

@Test
fun `空商品列表应返回总价0`() = runTest {
    val items = emptyList<OrderItem>()
    val user = User(id = "1", isVip = false)
    whenever(userRepository.getUser("1")).thenReturn(user)
    whenever(discountPolicy.applyPromotion(0.0)).thenReturn(0.0)

    val result = useCase(items, "1")

    assertTrue(result.isSuccess)
    assertEquals(0.0, result.getOrNull(), 0.001)
}

@Test
fun `商品数量为0时总价应为0`() = runTest {
    val items = listOf(OrderItem(price = 100.0, quantity = 0))
    val user = User(id = "1", isVip = false)
    whenever(userRepository.getUser("1")).thenReturn(user)
    whenever(discountPolicy.applyPromotion(0.0)).thenReturn(0.0)

    val result = useCase(items, "1")

    assertTrue(result.isSuccess)
    assertEquals(0.0, result.getOrNull(), 0.001)
}

@Test
fun `用户不存在应返回UserNotFound错误`() = runTest {
    val items = listOf(OrderItem(price = 100.0, quantity = 1))
    whenever(userRepository.getUser("non_existent")).thenReturn(null)

    val result = useCase(items, "non_existent")

    assertTrue(result.isFailure)
    assertTrue(result.exceptionOrNull() is UserNotFound)
}

注意:边界条件测试不是「随便写几个极端值就完事」。你需要结合业务场景来思考。比如金融类应用,金额的精度、四舍五入规则、负数金额的处理,都是边界条件的高发区。我曾经因为没测「金额为 0.01 元」的场景,导致线上优惠券发放逻辑出了 bug——0.01 元被当成了「免费」,直接跳过了扣款流程。

三、异常路径测试:验证「出错了怎么办」

异常路径测试,关注的是「当依赖出问题、数据格式不对、网络超时的时候,UseCase 的行为是否可控」。

很多开发者只测「一切正常」的情况,忽略了异常路径。但你想一下,线上环境里,网络抖动、数据库连接超时、第三方服务返回异常数据……这些才是常态。

异常路径测试通常包括:

异常类型 测试场景 预期行为
依赖抛出异常 Repository 抛出 IOException UseCase 返回 Failure,不崩溃
数据格式异常 返回的 JSON 缺少必要字段 UseCase 返回解析错误,不吞异常
超时异常 网络请求超时 UseCase 返回超时错误,可重试
空指针/非法参数 传入 null 或非法参数 UseCase 提前校验,返回明确错误

来看一个异常路径的测试示例:

@Test
fun `用户仓库抛出异常应返回Failure`() = runTest {
    val items = listOf(OrderItem(price = 100.0, quantity = 1))
    whenever(userRepository.getUser(any())).thenThrow(IOException("Network error"))

    val result = useCase(items, "1")

    assertTrue(result.isFailure)
    val exception = result.exceptionOrNull()
    assertTrue(exception is IOException)
    assertEquals("Network error", exception?.message)
}

@Test
fun `折扣策略返回负数应被拦截`() = runTest {
    val items = listOf(OrderItem(price = 100.0, quantity = 1))
    val user = User(id = "1", isVip = false)
    whenever(userRepository.getUser("1")).thenReturn(user)
    // 模拟折扣策略返回了不合法的负数
    whenever(discountPolicy.applyPromotion(100.0)).thenReturn(-50.0)

    val result = useCase(items, "1")

    // 预期:UseCase 应该校验并返回错误,而不是直接使用负数
    assertTrue(result.isFailure)
    assertTrue(result.exceptionOrNull() is InvalidDiscountException)
}

避坑指南:我曾经犯过一个错误——在异常路径测试里,只测了「抛异常」的情况,没测「异常被吞掉」的情况。有一次,一个同事在 Repository 层 catch 了所有异常并返回 null,结果 UseCase 层拿到 null 后直接 NPE 了。所以我的建议是:异常路径测试不仅要测「异常发生时」,还要测「异常被错误处理后」。比如 Repository 返回 null 时,UseCase 能不能优雅处理?

四、三种测试的组合策略

在实际项目中,我不会把三种测试割裂开来。我的做法是:

  1. 先写业务规则测试:把核心的 if-else、状态机、数据转换逻辑覆盖掉。这是「保底」的测试。
  2. 再补边界条件测试:针对每个输入参数,想 2-3 个极端值。空值、零值、超大值、特殊字符……这些是「防崩」的测试。
  3. 最后加异常路径测试:模拟依赖层抛异常、返回异常数据、超时等场景。这些是「防线上事故」的测试。

你可能会问:每个 UseCase 都要写这么多测试吗?我的回答是:核心业务 UseCase 必须写,工具类 UseCase 可以少写。比如「获取用户信息」这种简单的查询,边界条件和异常路径测一下就够了。但「订单结算」「优惠券发放」「权限校验」这类涉及多步判断的,三种测试一个都不能少。

总结一下:UseCase 单元测试的核心不是「覆盖率数字」,而是「对业务逻辑的信心」。当你改了某行代码,敢不敢不手动测试就直接提 MR?如果你的测试覆盖了业务规则、边界条件、异常路径,那你可以放心地按「Merge」按钮。

好了,这一章的内容就到这里。记住:测试不是为了应付 CI,而是为了让你睡得安稳。下一章我们聊聊 Repository 层的测试策略,到时候再细说。


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