29、UseCase单元测试:业务规则测试、边界条件测试、异常路径测试
说实话,很多团队写单元测试,最后都变成了「为了覆盖率而写测试」。我见过不少项目,覆盖率报表漂漂亮亮,但核心业务逻辑一改,测试全绿通过,线上却崩了。为什么?因为测试没打在点上。
今天我们就聊聊 UseCase 层的单元测试。这一层是业务逻辑的核心,也是我最看重的一层。我个人习惯把测试分成三类:业务规则测试、边界条件测试、异常路径测试。这三板斧下去,基本能把 UseCase 的坑填平。
核心观点:UseCase 测试不是为了测框架,而是为了测「当输入 X 时,业务规则是否按预期执行」。说白了,就是验证你的 if-else、状态机、数据转换逻辑对不对。
一、业务规则测试:验证「逻辑对不对」
业务规则测试,是 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 能不能优雅处理?
四、三种测试的组合策略
在实际项目中,我不会把三种测试割裂开来。我的做法是:
- 先写业务规则测试:把核心的 if-else、状态机、数据转换逻辑覆盖掉。这是「保底」的测试。
- 再补边界条件测试:针对每个输入参数,想 2-3 个极端值。空值、零值、超大值、特殊字符……这些是「防崩」的测试。
- 最后加异常路径测试:模拟依赖层抛异常、返回异常数据、超时等场景。这些是「防线上事故」的测试。
你可能会问:每个 UseCase 都要写这么多测试吗?我的回答是:核心业务 UseCase 必须写,工具类 UseCase 可以少写。比如「获取用户信息」这种简单的查询,边界条件和异常路径测一下就够了。但「订单结算」「优惠券发放」「权限校验」这类涉及多步判断的,三种测试一个都不能少。
总结一下:UseCase 单元测试的核心不是「覆盖率数字」,而是「对业务逻辑的信心」。当你改了某行代码,敢不敢不手动测试就直接提 MR?如果你的测试覆盖了业务规则、边界条件、异常路径,那你可以放心地按「Merge」按钮。
好了,这一章的内容就到这里。记住:测试不是为了应付 CI,而是为了让你睡得安稳。下一章我们聊聊 Repository 层的测试策略,到时候再细说。
公众号:蓝海资料掘金营,微信deep3321