跳转至

测试用例设计方法:等价类、边界值与判定表

一、先看一个漏测现场

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. 有效等价类要拆到「路径不同」为止。 领 1、2、3 张走同一段扣减,但 3 张会触发「达到上限」写库分支,必须单独留一条。
  2. 无效等价类一条用例只放一个错误。 {"count":4,"realName":false} 踩两个约束,返回「未实名」时无法判断限领校验是否生效。
  3. 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

化简必须按顺序做,否则会合并错:

  1. 先剔除不可达组合。 C1=N 且 C2=Y(未登录但已实名)数据上不可能。产品要求未登录统一返回 401 时保留为 A2;真不可达的组合要写注释,否则后人当漏测。
  2. 同动作、仅一个条件不同 → 用 - 合并。 规则 13~16 只有 C3 不同,动作都是 A2,合并为 C1=N, C2=-, C3=-, C4=- → A2。
  3. 动作不同不能合并,哪怕只差一个条件。 规则 1 是 A1、规则 5 是 A3,不能因为「都登录了」就合并。
  4. 校验顺序决定返回值,必须由需求拍板。 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
coverage run -m pytest tests/ -q
coverage report -m --fail-under=70
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

两条判定标准:

  1. 分支覆盖率明显低于行覆盖率 = 只测了主路径。 上面行 91%、分支 85%,差额就是 if (count > 3) 这类只有 true 没有 false 的分支。
  2. 覆盖率高不等于用例有效。 assert resp.status_code == 200 能把行覆盖率刷上去,金额算错也返回 200。断言要落在业务字段:assert body["payAmount"] == "90.00"。

一个需求的用例评审通过线:每个 AC 至少 1 条用例 + 每条判定表规则有对应用例 + 全部合法状态迁移有对应用例 + 边界覆盖 n-1/n/n+1,四条缺一条就打回补设计,而不是靠条数凑数。

相关阅读