测试用例设计方法:等价类、边界值与判定表¶
一、先看一个漏测现场¶
2026-08 一次优惠券活动上线,2 小时内收到 3 类客诉,对应 3 条线上日志:
1) 领第 4 张券:HTTP 500
{"code":500,"message":"Internal Server Error","traceId":"9f2c41ab7e"}
2) 订单金额恰好 100.00 未抵扣:
[CouponCalc] orderAmount=100.00 threshold=100.0 discount=0.00
3) 取消订单后券未退回,再次下单:
{"code":40021,"message":"coupon already used","couponId":"C20260801001"}
翻出这次迭代的 32 条用例:26 条是「正常领取成功」,边界只有 2 条(领 0 张、领 3 张),金额只测了 100.01,状态只走了主流程。用例数量不少,但没有一条打在上述三个点上。
问题不在态度,在方法:等价类只写了「有效/无效」两行,边界值抄了模板没算 n+1,状态之间没做迁移矩阵。下面用同一条需求把这些方法走完。
二、需求原文与可测性拆解¶
需求文档(节选,保留原始歧义):
用户可在活动页领取优惠券,每人每个活动限领 3 张,券总量 10 万张,领完即止。券面额 10 元,订单满 100 元可用,有效期 7 天,逾期自动失效。 下单时自动选择最优券,支付成功后核销;订单取消或超时关闭时券退回。仅实名用户可领取。
拆成 4 类可测对象:
| 对象 | 具体内容 | 需要澄清的点 |
|---|---|---|
| 输入域 | 用户、活动、领取张数、订单金额、时间 | 张数是否允许一次领多张?金额是否含运费? |
| 规则组合 | 登录 + 实名 + 库存 + 限领 | 四个条件同时不满足时先提示哪个? |
| 状态 | 券的领取、锁定、核销、退回、过期 | 锁定后过期算谁的?退款后券能否再用? |
| 非功能 | 并发领取、重复提交、时区 | 库存扣减是 DB 还是 Redis? |
三、等价类划分:先定输入域,再定代表值¶
等价类不是「把输入分成有效和无效」,而是为每个输入域列出所有会走不同代码路径的集合,一个集合只取一个代表值。
| 输入域 | 有效等价类(代表值) | 无效等价类(代表值) | 判定依据 |
|---|---|---|---|
| 领取张数 | 1(E1)、2(E2)、3(E3) | 0(E4)、4(E5)、-1(E6)、1.5(E7) | 每人限 3 张,接口 int |
| 用户登录态 | token 有效(E8) | 未登录(E9)、token 过期(E10) | 网关鉴权 |
| 实名状态 | 已实名(E11) | 未实名(E12)、审核中(E13) | 需求新增约束 |
| 活动库存 | >0(E14) | =0(E15) | 领完即止 |
| 订单金额 | ≥100(E16) | <100(E17)、负数(E18)、三位小数(E19) | 门槛判断,单位元 |
| 活动时间 | 活动期内(E20) | 开始前 1 秒(E21)、结束后 1 秒(E22) | 接口与定时任务双校验 |
三个容易做错的细节:
- 有效等价类要拆到「路径不同」为止。 领 1、2、3 张走同一段扣减,但 3 张会触发「达到上限」写库分支,必须单独留一条。
- 无效等价类一条用例只放一个错误。
{"count":4,"realName":false}踩两个约束,返回「未实名」时无法判断限领校验是否生效。 null、""、字段缺失是三个等价类。 Jackson 下null走@NotNull,缺字段抛MissingServletRequestParameterException,""走业务校验,返回码不同。
四、边界值:0/1/n-1/n/n+1 怎么取¶
边界值取的是会改变判断结果的那个点及其两侧。凡代码出现 <、<=、>、>=,就有一个边界。
| 字段 | n | 0 | 1 | n-1 | n | n+1 | 预期 |
|---|---|---|---|---|---|---|---|
| 每人限领张数 | 3 | count=0 → 400 |
1 张成功 | 2 张成功 | 3 张成功 | 4 张 → 40021 超限(非 500) | 3 是闭区间上界 |
| 订单门槛(元) | 100.00 | 0.00 不抵扣 | 0.01 不抵扣 | 99.99 不抵扣 | 100.00 抵扣 10.00 | 100.01 抵扣 10.00 | 门槛含等于 |
| 有效期(天) | 7 | 领取瞬间有效 | 第 1 天 23:59:59 有效 | 第 6 天有效 | 第 7 天 23:59:59 有效 | 第 8 天 00:00:00 已过期 | 按自然日截断 |
| 券码长度 | 16 | "" → 必填 |
1 位格式错 | 15 位格式错 | 16 位通过 | 17 位格式错 | 定长 |
金额边界必须用 Decimal 构造:
# Python 3.11,Decimal 避免二进制浮点误差
from decimal import Decimal
assert Decimal("100.00") == Decimal("100") # True
assert 0.1 + 0.2 == 0.3 # False
assert Decimal("0.1") + Decimal("0.2") == Decimal("0.3") # True
assert Decimal("100.00") >= Decimal("100") # True,应抵扣
时间边界要写清时区。券表 expire_time 为无时区 datetime,业务按 Asia/Shanghai 解释;定时任务跑在 UTC 容器里时,2026-10-08 00:00:00 会被当成北京时间 08:00,过期提前 8 小时。
# 输出必须是 Asia/Shanghai,否则所有有效期用例预期都要重算
docker exec -it coupon-service date
docker exec -it coupon-service cat /etc/timezone
五、判定表:16 条组合如何化简成 5 条规则¶
四个条件、每个两值,理论 2^4 = 16 条规则。条件桩:C1 已登录、C2 已实名、C3 库存>0、C4 未领满 3 张。动作桩:A1 领取成功、A2 未登录、A3 需实名、A4 已领完、A5 达上限。
规则号 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
C1 已登录 Y Y Y Y Y Y Y Y N N N N N N N N
C2 已实名 Y Y Y Y N N N N Y Y Y Y N N N N
C3 有库存 Y Y N N Y Y N N Y Y N N Y Y N N
C4 未领满 Y N Y N Y N Y N Y N Y N Y N Y N
动作 A1 A5 A4 A4 A3 A3 A3 A3 A2 A2 A2 A2 A2 A2 A2 A2
化简必须按顺序做,否则会合并错:
- 先剔除不可达组合。 C1=N 且 C2=Y(未登录但已实名)数据上不可能。产品要求未登录统一返回 401 时保留为 A2;真不可达的组合要写注释,否则后人当漏测。
- 同动作、仅一个条件不同 → 用
-合并。 规则 13~16 只有 C3 不同,动作都是 A2,合并为C1=N, C2=-, C3=-, C4=- → A2。 - 动作不同不能合并,哪怕只差一个条件。 规则 1 是 A1、规则 5 是 A3,不能因为「都登录了」就合并。
- 校验顺序决定返回值,必须由需求拍板。 C2=N 且 C3=N 时返回 A3 还是 A4,取决于代码先校验实名还是先查库存,这是需求缺口不是测试能猜的。
化简结果:
| 规则 | C1 | C2 | C3 | C4 | 动作 | 对应用例 |
|---|---|---|---|---|---|---|
| R1 | Y | Y | Y | Y | A1 领取成功 | TC-01 |
| R2 | Y | Y | Y | N | A5 已达上限 | TC-02 |
| R3 | Y | Y | N | - | A4 已领完 | TC-03 |
| R4 | Y | N | - | - | A3 需实名 | TC-04 |
| R5 | N | - | - | - | A2 未登录 | TC-05 |
16 条组合 → 5 条规则 → 5 条用例,规则覆盖率 = 已执行规则数 / 化简后规则数 = 5/5。只写 R1 就是 20%,写 30 条「正常领取」变体也补不回来。
六、Pairwise:108 种组合压到 12 条¶
券详情页要验证「平台 × 浏览器 × 网络 × 账号」,全排列 3×4×3×3 = 108 条。Pairwise 保证任意两个因素的取值组合至少出现一次。
Platform: Windows 10, Windows 11, macOS 14
Browser: Chrome, Edge, Firefox, Safari
Network: 4G, WiFi, WeakNet
Account: NewUser, OldUser, Verified
IF [Platform] = "Windows 10" THEN [Browser] <> "Safari";
IF [Network] = "WeakNet" THEN [Account] <> "NewUser";
# PICT 3.7.4.0
pict coupon.txt
pict coupon.txt /r:2 > coupon_cases.txt # 固定随机种子,便于复现同一组用例
(Get-Content coupon_cases.txt).Count # 12 行 + 1 行表头
Platform Browser Network Account
Windows 10 Chrome 4G NewUser
Windows 10 Firefox WeakNet OldUser
Windows 11 Edge WiFi NewUser
跨平台团队没有 PICT 时用 allpairspy(Python 3.11 + allpairspy 2.5.1):
# pip install allpairspy==2.5.1
from allpairspy import AllPairs
params = [
["Windows 10", "Windows 11", "macOS 14"],
["Chrome", "Edge", "Firefox", "Safari"],
["4G", "WiFi", "WeakNet"],
["NewUser", "OldUser", "Verified"],
]
def valid(row):
platform, browser, network, account = row
if platform == "Windows 10" and browser == "Safari":
return False
if network == "WeakNet" and account == "NewUser":
return False
return True
rows = list(AllPairs(params, filter_func=valid))
判定标准:组合数应接近「最大取值数 × 次大取值数」(4×3=12)。输出 30 多条说明约束写少了或把约束当硬过滤;输出 6 条要检查是否漏传因素。
七、状态迁移法:7 条合法迁移与 35 条非法迁移¶
券状态机:NEW 未领取、AVAILABLE 已领未用、LOCKED 下单锁定、USED 已核销、EXPIRED 已过期、REFUNDED 已退回。
| 当前状态 | 事件 | 目标状态 | 类型 |
|---|---|---|---|
| NEW | grant | AVAILABLE | 合法 |
| AVAILABLE | lock | LOCKED | 合法 |
| AVAILABLE | expire | EXPIRED | 合法 |
| LOCKED | use | USED | 合法 |
| LOCKED | release | AVAILABLE | 合法 |
| LOCKED | expire | EXPIRED | 合法(过期是否退回需确认) |
| USED | refund | REFUNDED | 合法 |
| EXPIRED / USED | grant | - | 非法 |
| NEW / REFUNDED | use | - | 非法 |
6 状态 × 7 事件 = 42 格,合法 7、非法 35。0-switch 覆盖 = 已测合法迁移 / 7。把转移表直接变成用例,避免漏格:
# pytest 8.3,Python 3.11
import pytest
class IllegalStateTransition(Exception):
pass
TRANSITIONS = {
("NEW", "grant"): "AVAILABLE",
("AVAILABLE", "lock"): "LOCKED",
("AVAILABLE", "expire"): "EXPIRED",
("LOCKED", "use"): "USED",
("LOCKED", "release"): "AVAILABLE",
("LOCKED", "expire"): "EXPIRED",
("USED", "refund"): "REFUNDED",
}
@pytest.mark.parametrize("src,event,dst", [(s, e, d) for (s, e), d in TRANSITIONS.items()])
def test_legal_transition(coupon_factory, src, event, dst):
coupon = coupon_factory(status=src)
coupon.apply(event)
assert coupon.status == dst
STATES = ["NEW", "AVAILABLE", "LOCKED", "USED", "EXPIRED", "REFUNDED"]
EVENTS = ["grant", "lock", "use", "release", "expire", "refund", "rollback"]
@pytest.mark.parametrize(
"src,event",
[(s, e) for s in STATES for e in EVENTS if (s, e) not in TRANSITIONS],
)
def test_illegal_transition(coupon_factory, src, event):
coupon = coupon_factory(status=src)
with pytest.raises(IllegalStateTransition) as exc:
coupon.apply(event)
assert f"{src} --{event}-->" in str(exc.value)
35 条非法迁移一次覆盖,任何一条没抛异常就是状态机漏洞。上表 LOCKED→expire 是真实事故点:过期任务只扫 AVAILABLE,锁定中的券永不过期,用户取消订单后拿到一张已过期券。
八、从需求文字到用例表¶
P0 资金/主流程阻断,P1 主功能受损有绕过,P2 次要异常,P3 文案体验。
| ID | 标题 | 前置/数据 | 预期结果 | 优先级 | 方法 |
|---|---|---|---|---|---|
| TC-01 | 实名用户领 1 张 | 已登录已实名,库存 100,count=1 |
200;券 AVAILABLE;库存 99;发放记录 1 条 | P0 | E1 + R1 |
| TC-02 | 第 3 张成功、第 4 张被拒 | 已领 2 张,再连领 2 次 | 第 3 张 200;第 4 张 40021,库存不再减少 | P0 | 边界 n/n+1 + R2 |
| TC-03 | 库存为 0 领取 | 活动库存置 0 | 40022「券已领完」,不产生发放记录 | P1 | E15 + R3 |
| TC-04 | 未实名领取 | 已登录、未实名 | 40301「请先实名认证」,库存不变 | P1 | E12 + R4 |
| TC-05 | 未登录领取 | 无 token | 401,响应体 code:40100 |
P1 | R5 |
| TC-06 | 金额 100.00 抵扣 | 持有可用券,下单 100.00 | 应付 90.00,日志 discount=10.00 |
P0 | 边界 n |
| TC-07 | 金额 99.99 不抵扣 | 下单 99.99 | 应付 99.99,券不锁定 | P0 | 边界 n-1 |
| TC-08 | 金额 100.01 抵扣 | 下单 100.01 | 应付 90.01 | P0 | 边界 n+1 |
| TC-09 | 金额 100.005 舍入 | 下单 100.005 | 按分四舍五入后判定,结果与财务口径一致 | P1 | 精度边界 |
| TC-10 | 取消订单券退回 | 券 LOCKED,取消订单 | 回 AVAILABLE,lock_order_id 置空,可再次下单 |
P0 | LOCKED→AVAILABLE |
| TC-11 | 取消时券已过期 | 券 LOCKED 且已过 expire_time | 回 EXPIRED,不允许使用 | P1 | LOCKED→EXPIRED |
| TC-12 | 支付成功核销 | 券 LOCKED,回调成功 | 券 USED,used_time 有值,不可重复核销 |
P0 | LOCKED→USED |
| TC-13 | 重复支付回调 | 券已 USED,重放回调 3 次 | 只核销 1 次,库存与流水不重复 | P0 | 幂等 + 非法迁移 |
| TC-14 | 退款后券状态 | 券 USED,订单全额退款 | 券 REFUNDED;能否再领按产品规则断言接口返回 | P1 | USED→REFUNDED |
| TC-15 | 活动开始前 1 秒领取 | 活动 10-01 00:00 开始,09:59:59 请求 | 40023「活动未开始」 | P2 | 时间边界 |
| TC-16 | 并发领券不超发 | 库存 10,hey -n 100 -c 100 |
成功 10 次,其余 40022;DB 与 Redis 库存一致 | P0 | 并发 + 对账 |
| TC-17 | Safari/Windows 组合 | Pairwise 输出 12 组 | 渲染与领取按钮状态正确 | P2 | Pairwise |
hey -n 100 -c 100 -m POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{"activityId":1001,"count":1}' \
https://test.qalab.cn/api/v1/coupon/grant
并发验收不看「有没有报错」,看三个数:成功数 = 库存数、coupon_stock 最终为 0 不为负、SELECT user_id, COUNT(*) FROM coupon_grant_record GROUP BY user_id HAVING COUNT(*) > 3 返回 0 行。
九、五个最常见的错误¶
| 错误做法 | 为什么错 | 正确做法 |
|---|---|---|
| 把边界值当成等价类的代表值 | 取 100 无法发现 >= 写成 > 的 bug |
边界单独成组取 n-1/n/n+1,等价类取区间内部值 |
| 把 n+1 归入有效等价类 | 4 张券是拒绝路径,预期会被写成「成功」 | 按业务规则归类,n+1 属无效类但必须单独测 |
| 判定表只列 C1=Y 的 8 条 | 未登录、未实名分支全漏,只覆盖一半 | 2^n 全列后再合并,合并依据写进用例备注 |
| 画因果图但不转判定表 | 图是中间产物,不落表就没有可执行用例 | 判定表 + 4 种约束(E 互斥、I 包含、O 唯一、R 要求)标注后生成用例 |
| 状态迁移只测合法迁移 | 非法跳转才是资损与脏数据来源 | 状态 × 事件全矩阵,非法格用 pytest.raises 断言 |
十、覆盖率怎么度量¶
| 口径 | 公式 | 本例目标 | 数据来源 |
|---|---|---|---|
| 需求覆盖率 | 有对应用例的 AC 条数 / AC 总条数 | 100% | 需求条目 ↔ 用例 ID 映射 |
| 等价类覆盖率 | 已覆盖等价类数 / 识别出的等价类数 | ≥95%(22 个类) | 用例表「方法」列 |
| 判定表规则覆盖率 | 已执行规则数 / 化简后规则数 | 100%(5/5) | 规则表 ↔ 用例 |
| 状态迁移覆盖率 | 已测合法迁移 / 合法迁移总数 | 100%(7/7),非法 ≥90% | 转移矩阵 |
| 代码覆盖率 | 行覆盖 / 分支覆盖 | 增量 ≥70%,核心 ≥80% | coverage / JaCoCo |
Name Stmts Miss Branch BrPart Cover Missing
-----------------------------------------------------------------------
app/coupon/service.py 186 12 48 5 91% 88-92, 140
app/coupon/calculator.py 74 3 22 2 93% 57
-----------------------------------------------------------------------
TOTAL 260 15 70 7 91%
# Java:Maven 3.9 + jacoco-maven-plugin 0.8.12
mvn -q test jacoco:report
cat target/site/jacoco/jacoco.csv
两条判定标准:
- 分支覆盖率明显低于行覆盖率 = 只测了主路径。 上面行 91%、分支 85%,差额就是
if (count > 3)这类只有 true 没有 false 的分支。 - 覆盖率高不等于用例有效。
assert resp.status_code == 200能把行覆盖率刷上去,金额算错也返回 200。断言要落在业务字段:assert body["payAmount"] == "90.00"。
一个需求的用例评审通过线:每个 AC 至少 1 条用例 + 每条判定表规则有对应用例 + 全部合法状态迁移有对应用例 + 边界覆盖 n-1/n/n+1,四条缺一条就打回补设计,而不是靠条数凑数。