结论先行:"造一行数据"和"造一张边界地图"是两件事。前者解决"能跑通",后者解决"能拦住"。我 8 年测开,经手过 5 起"测试全绿、线上 P2"的复盘,根因没有一起是逻辑写错,全是造数没覆盖到边界。这篇给一份可直接抄进用例评审的"造数三清单",并附我踩过的坑。
一、现象:单跑全绿的 1200 条用例,拦不住 5 起 P2
先给数字。我维护一条 B 端 SaaS 的计费+结算链路,接口自动化 1200 条用例(Java 17 + JUnit 5.10 + Testcontainers + MySQL 8.0),CI 常年全绿。但 2024 年 Q3–Q4 半年里线上出了 5 起 P2/P3,复盘下来没有一起是"接口挂了",全是造数没覆盖到边界:
- 0.01 元差:金额比较把
>=写成>,测试数据全是整元(100.00 / 200.00),差一分支从没被触发; - 跨日临界:订单"下单 24h 未支付自动取消",造数用
now() - 23h和now() - 25h,唯独没造now() - 23h59m59s和now() - 24h00m01s,时区一漂移就漏单; - 枚举静默变更:状态机
SUCCESS → SUCCEEDED,测试只造了SUCCESS,新增态SUCCEEDED没人造数验证下游分支; - 空值 / 超长:商家名 1 字节时走"默认名"分支、256 字节时截断,测试只造了 4 字节中文;
- 并发临界:幂等键在 200ms 窗口内重复提交,单跑永远只发一次,并发一上就重复扣款。
抽样统计:1200 条里有 560 条(约 47%)是"造一个常规值 + 断言 200/非空"型用例,边界值用例只占 8%。造数强度越低,"全绿"就越不可信。
二、根因:造数不是"插一行",是"覆盖一个值域"
把 5 起 P2 归因后,机制很清楚,三条:
- 值域 ≠ 单点。一个字段是一个值域(金额 [0, 99999999.99]、商家名 [1, 256]、时间 [0, +∞)),造一个值是"采样",造边界才是"覆盖"。常规值能跑通主流程,但缺陷几乎都藏在值的边缘:0、1、max、max+1、负数、空、超长、临界时间、临界状态。
- 临界态是"相邻两态的接缝"。状态机相邻两态之间、时间窗口的开闭边界、金额抹零阈值、并发幂等窗口——这些"接缝"是逻辑分支最密集的地方,也是最容易被"常规值"滑过去的地方。
- 弱断言放大了造数缺口。造数没造到边界 + 断言只验 200/非空,等于把"接口没崩"当成"接口正确"。两条叠加,"全绿"就成了自欺。
核心结论:造数要三张清单——边界值、临界态、不变量。每张都可机检、可追踪、可在用例评审时逐条打勾。
三、具体做法:三张清单 + 造数工厂 + 不变量断言
清单 A:边界值(每个字段必造 7 个值)
对每个业务字段,固定造 7 个值:0 / 1 / min / max / max+1 / 负数 / 空(或超长)。以金额字段为例:
# fixtures/amount.py
from decimal import Decimal
AMOUNT_BOUNDARY = [
Decimal("0.00"), # 下界
Decimal("0.01"), # 最小可支付
Decimal("99.99"), # 抹零阈值下
Decimal("100.00"), # 抹零阈值上(业务规则敏感)
Decimal("99999999.99"), # 上界
Decimal("100000000.00"),# 上界 + 1(应被拒)
Decimal("-0.01"), # 负数(应被拒)
]
关键细节:99.99 和 100.00 这一对不是拍脑袋,是业务规则阈值两侧各取一个。抹零、满减、阶梯计价、税费起征点,所有"阈值型规则"都要在阈值两侧各造一个值。
清单 B:临界态(状态机 + 时间窗 + 并发窗)
状态机:把状态机的每条边画出来,每条边的"源态 + 目标态"各造一次,再加"非法跳转"(源态直接到不合法目标态,应被拒):
# fixtures/order_status.py
TRANSITIONS = [
("PENDING", "PAID"), # 合法
("PENDING", "CANCELLED"), # 合法
("PAID", "REFUNDING"), # 合法
("CANCELLED", "PAID"), # 非法,应 4xx
("REFUNDED", "PAID"), # 非法,应 4xx
]
时间窗:开闭边界各取一个 + 窗口内一个 + 窗口外一个:
from datetime import datetime, timedelta
BASE = datetime(2024, 11, 1, 12, 0, 0)
TIME_BOUNDARY = {
"inside": BASE, # 窗口内
"left_open": BASE - timedelta(seconds=1),# 窗口左外
"left_edge": BASE, # 窗口左边界(含)
"right_edge": BASE + timedelta(hours=24, seconds=-1), # 窗口右边界(不含)
"right_out": BASE + timedelta(hours=24, seconds=1), # 窗口右外
}
并发窗:幂等键的 TTL 窗口(我们的是 200ms),造"窗口内重复"和"窗口外重复"两种:
// 用 CyclicBarrier 让 N 个线程在同一毫秒内打到同一幂等键
CyclicBarrier barrier = new CyclicBarrier(3);
for (int i = 0; i < 3; i++) {
pool.submit(() -> {
barrier.await(); // 对齐到同一时刻
client.post("/pay", body); // 同一 idempotencyKey
});
}
清单 C:不变量(断言必须校验的"值")
每个接口列 3–5 条跨请求、跨时间都成立的不变量,断言里逐条校验,而不是只验 200:
# invariants.py
def assert_payment_invariants(resp, request_amount):
assert resp["status"] in {"SUCCESS", "PROCESSING"} # 枚举白名单
assert resp["amount"] == request_amount # 金额守恒
assert resp["created_at"] <= resp["updated_at"] # 时间单调
assert len(resp["order_no"]) == 32 # 长度契约
关键细节:不变量清单要随业务规则同步演进。每次改金额规则、改状态机、改枚举,都要回来更新这张表。
造数工厂:单一数据源,杜绝魔法值漂移
把上面三张清单收敛到一个 fixtures/ 目录,用例只引用工厂,不直接写魔法值:
fixtures/
amount.py # 金额边界
order_status.py # 状态机边
time_window.py # 时间窗口
merchant.py # 商家名长度边界
invariants.py # 不变量断言
再在 CI 里加一条 ArchUnit / ruff 规则:用例体内禁止出现 Decimal("100.00")、"SUCCESS"、LocalDateTime.now() 这类字面量,必须从 fixtures/ 导入。这一条规则上线后,三个月内魔法值漂移类 bug 从 4 起降到 0。
四、踩过的坑(反直觉之处)
- "造了边界值"≠"造对了边界值"。我们最初造了
0 / 1 / max,但没造max+1和min-1,结果一个溢出 bug 漏了三个月。边界是"两侧",不是"单侧"。 - 时区是造数的隐形杀手。
datetime.now()在 CI 机器(UTC)和 staging(CST)上差 8 小时,同一个"24h 窗口"用例在 CI 全绿、staging 偶挂。修法:造数里禁用本地时区,统一用 UTC 或显式ZoneId。 - 并发窗口的"对齐"比"数量"重要。我一开始用
ThreadPoolExecutor发 10 个并发请求,结果线程池调度让 10 个请求散在 80ms 里,幂等窗口(200ms)虽然都进来了,但没对齐到同一毫秒,测不出真正的竞态。换成CyclicBarrier对齐后才复现。 - 弱断言会"吃掉"造数的努力。造数造了 7 个边界值,但断言只写
assert resp is not None,等于白造。造数和断言强度要成对升级。 - 魔法值会"漂移"。同一个
amount = 100.00,6 个用例各自写,三个月后有的变成100、有的变成100.00、有的变成"100.00"(字符串),对账分支一走就漏。修法:单一数据源 + 静态检查。
五、结论与可复用清单
可复用清单(可直接抄进用例评审模板):
- [ ] 每个业务字段:造
0 / 1 / min / max / max+1 / 负数 / 空7 个值 - [ ] 每个"阈值型规则"(抹零、满减、起征点):阈值两侧各造一个
- [ ] 状态机:每条合法边 + 每条非法边各造一次
- [ ] 时间窗口:左外 / 左边界 / 窗口内 / 右边界 / 右外 5 个点
- [ ] 并发窗口:窗口内重复 + 窗口外重复,用
CyclicBarrier对齐 - [ ] 每个接口:3–5 条不变量断言(枚举白名单 / 金额守恒 / 时间单调 / 长度契约)
- [ ] 造数收敛到
fixtures/单一数据源,CI 静态检查禁止用例体内魔法值 - [ ] 时区统一 UTC 或显式
ZoneId,禁用datetime.now()无参调用
一句话:造数不是"插一行",是"覆盖一个值域"。边界值、临界态、不变量三张清单,是"全绿"从"自欺"变成"可信"的分水岭。
参考:
- @谢星河-888《测试数据工厂的隐性耦合:Fixture 魔法值让 23 个用例互相依赖》
- @谢清和-928《接口断言分层:把"200+非空"升级为契约/值/不变式三层》