4、迁移条件设计:条件表达式陷阱、复合条件拆分、条件覆盖度、边界条件处理
状态迁移的条件,说白了就是「什么时候该走」。很多人觉得这还不简单?if 判断一下不就完了。嗯,我刚开始也这么想,直到有一次线上事故让我彻底改了态度。
那次是一个订单状态机,从「待支付」到「已支付」的条件里,我写了个 order.status == 'pending' && order.payAmount > 0。看起来没问题对吧?结果用户支付了一分钱,条件也通过了。后来才发现,边界值根本没处理。所以这一章,咱们就专门聊聊条件设计里的那些坑。
4.1 条件表达式的常见陷阱
条件表达式看着简单,但坑特别多。我总结了几类高频问题:
| 陷阱类型 | 错误示例 | 问题说明 |
|---|---|---|
| 隐式类型转换 | if (status == 1) |
JS 中 1 == '1' 为 true,但 1 === '1' 为 false |
| 空值未防御 | user.role === 'admin' |
user 为 null 时直接抛 NPE |
| 浮点数精度 | amount === 0.1 + 0.2 |
0.1+0.2 实际是 0.30000000000000004 |
| 字符串前后空格 | name === '张三' |
用户输入可能带空格,导致匹配失败 |
在支付回调里用 if (result.code == 0) 判断成功。结果第三方返回了字符串 '0',条件通过了,但后续解析 JSON 时报错。从那以后,我所有条件判断都强制用 ===,并且先做类型校验。
4.2 复合条件的拆分策略
一个状态迁移条件里塞五六个 && 和 ||,你想想看,三个月后你自己都看不懂。我个人习惯是:复合条件必须拆分。
拆分的核心原则:
- 单一职责:每个子条件只判断一件事
- 可命名:子条件能起个有意义的名字
- 可测试:每个子条件能单独写单元测试
举个例子,一个「允许退款」的条件:
// ❌ 不推荐:一坨条件
if (order.status === 'paid' && order.payTime &&
Date.now() - order.payTime < 7 * 24 * 3600 * 1000 &&
!order.isRefunding && order.refundCount === 0) {
// 允许退款
}
// ✅ 推荐:拆分后
const isPaid = order.status === 'paid';
const hasPayTime = order.payTime != null;
const withinRefundPeriod = Date.now() - order.payTime < 7 * 24 * 3600 * 1000;
const notRefunding = !order.isRefunding;
const noRefundHistory = order.refundCount === 0;
if (isPaid && hasPayTime && withinRefundPeriod && notRefunding && noRefundHistory) {
// 允许退款
}
我习惯把每个子条件提取成函数,比如 isWithinRefundPeriod(order)。这样条件本身就成了业务文档,读代码的人一眼就能看懂意图。
4.3 条件覆盖度:别漏了分支
条件覆盖度,说白了就是你的测试有没有把所有条件组合都测到。很多项目只测了「正常路径」,结果线上出了「异常组合」就崩了。
我见过一个真实案例:状态机从 A→B 的条件是 a && (b || c)。测试只覆盖了 a=true, b=true, c=false 和 a=true, b=false, c=true。结果线上出现了 a=true, b=true, c=true 的情况,触发了意料之外的状态迁移。
条件覆盖度的几个层次:
- 语句覆盖:每行代码都执行到(最弱)
- 分支覆盖:每个 if/else 都走到
- 条件覆盖:每个原子条件都取到 true/false
- MC/DC 覆盖:每个原子条件独立影响结果(航空级标准)
对于状态机,我建议至少做到条件覆盖。也就是每个原子条件都要测 true 和 false 两种情况。
写条件时,我会列一张真值表。比如条件 A && (B || C),我会列出所有 8 种组合,然后标记哪些组合是「允许迁移」的。这样测试用例就不会漏。
4.4 边界条件处理
边界条件是最容易出问题的地方。我遇到过好几次线上事故,都是因为边界没处理好。
常见的边界类型:
| 边界类型 | 示例 | 处理建议 |
|---|---|---|
| 数值边界 | 金额 > 0,但 0.01 和 0.00 的区别 | 明确包含/不包含端点,用 >= 或 > |
| 时间边界 | 7 天内退款,第 7 天 23:59:59 算不算? | 统一用时间戳比较,避免日期字符串 |
| 集合边界 | 数组为空、只有一个元素、满容量 | 分别写测试用例覆盖 |
| 状态边界 | 初始状态、终态、循环状态 | 终态要禁止任何迁出 |
一个「7 天内可退款」的条件,我写的是 Date.now() - order.payTime < 7 * 24 * 3600 * 1000。结果用户在第 7 天最后一秒发起退款,条件通过了,但退款处理耗时 2 秒,处理时已经超过 7 天了。后来我改成 <= 并加了 5 秒的缓冲时间。
4.5 条件设计的整体流程
说了这么多,咱们来画个图,把条件设计的整体流程串起来。
4.6 实战建议总结
最后,我把条件设计的核心要点总结一下:
- 用 === 代替 ==,避免隐式类型转换
- 复合条件必须拆分,每个子条件要有名字
- 边界值要明确,用 >= 还是 > 要写清楚
- 覆盖度要达标,至少做到条件覆盖
- 时间比较用时间戳,别用日期字符串
- 浮点数比较加容差,比如
Math.abs(a - b) < 0.0001
每次写完条件,我都会问自己三个问题:这个条件别人能看懂吗?边界情况处理了吗?测试能覆盖所有分支吗?如果有一个回答是否定的,我就重新设计。
条件设计看似简单,但往往是状态机出问题的根源。你想想看,一个状态迁移错了,可能整个业务流程就乱了。所以,花时间把条件设计好,值得。