接口测试用例设计:参数、场景与异常¶
“接口通了”不等于“接口对了”。一条用例如果没有明确的判定依据(看哪个字段、看哪张表、看哪条消息),执行结果就只能靠感觉。下面用一个真实形态的下单接口,从参数到状态机逐条给出输入、预期和判定点。
一、被测接口与判定基线¶
POST /api/v1/orders
Authorization: Bearer <token>
Content-Type: application/json
Idempotency-Key: 7d2c1f0a-...
{"userId":1001,"addressId":88,"couponId":"C2026",
"items":[{"skuId":1001,"count":2}]}
成功响应 201 Created:
{"code":0,"data":{"orderId":9001,"status":"PENDING",
"payAmount":"100.00","expireAt":"2026-10-22T12:00:00+08:00"}}
判定基线(每条用例至少命中其中两类):
| 维度 | 判定内容 |
|---|---|
| 协议层 | 状态码、Content-Type、Location(创建类接口) |
| 业务层 | code、message、错误字段定位(loc 或字段名) |
| 数据层 | orderId、status、payAmount 的值与格式 |
| 副作用 | 库存、优惠券状态、订单表记录数、发出的消息 |
| 可观测 | X-Request-Id 能在日志中检索到同一请求 |
只断言 status_code == 200 是最典型的假通过:业务失败也返回 200 的实现很多,错误体里 code 才是结论。
二、参数级用例¶
2.1 必填、类型、长度¶
| ID | 输入 | 预期 | 判定依据 |
|---|---|---|---|
| P-01 | 删掉 userId |
400/422 | code=PARAM_INVALID,定位到 userId |
| P-02 | userId="1001" |
400/422 | 不因弱类型转换而静默通过 |
| P-03 | userId=0、userId=-1 |
400 | 用户不存在与非法 ID 要能区分 |
| P-04 | items=[] |
400 | code=ITEMS_EMPTY,不生成订单 |
| P-05 | items=null |
400 | 不能 500,也不能当空数组处理 |
| P-06 | items 传对象 {} |
400/422 | 类型错误要定位到 items |
| P-07 | items 长度 100(上限) |
201 | 上限值本身合法 |
| P-08 | items 长度 101 |
400 | code=ITEMS_TOO_MANY |
| P-09 | count=0 / count=-1 |
400 | 拒绝,且库存不变 |
| P-10 | count=1.5 |
400 | 整数字段不接受小数 |
| P-11 | count="2" |
400 | 不依赖框架自动转型 |
| P-12 | 重复 skuId 两行 |
400 或合并 | 行为必须明确并在文档里写死 |
2.2 枚举、边界、特殊字符¶
| ID | 输入 | 预期 | 判定依据 |
|---|---|---|---|
| P-13 | couponId 不存在 |
400 | code=COUPON_NOT_FOUND |
| P-14 | couponId 已过期 / 已使用 / 不属于当前用户 |
400 或 403 | 三种原因错误码不同,便于前端提示 |
| P-15 | addressId 属于他人 |
403 | 不能返回 200 后按地址发货 |
| P-16 | remark 传 500 字 |
201 | 长度上限内正常落库 |
| P-17 | remark 传 501 字 |
400 | 边界 +1 必须拒绝 |
| P-18 | remark 含 ' OR 1=1 --、<script>alert(1)</script>、😀、\n |
201 | 原样存储或转义,不得 500、不得截断 |
| P-19 | body 1MB / 10MB | 413 或 400 | 超限要有明确错误,不能挂住连接 |
参数化执行(把表变成代码):
import pytest
CASES = [
("P-01", {"addressId": 88, "items": [{"skuId": 1001, "count": 1}]}, 400, "PARAM_INVALID"),
("P-03", {"userId": 0, "addressId": 88, "items": [{"skuId": 1001, "count": 1}]}, 400, "PARAM_INVALID"),
("P-04", {"userId": 1001, "addressId": 88, "items": []}, 400, "ITEMS_EMPTY"),
("P-09", {"userId": 1001, "addressId": 88, "items": [{"skuId": 1001, "count": 0}]}, 400, "COUNT_INVALID"),
("P-10", {"userId": 1001, "addressId": 88, "items": [{"skuId": 1001, "count": 1.5}]}, 400, "COUNT_INVALID"),
("P-08", {"userId": 1001, "addressId": 88,
"items": [{"skuId": 1000 + i, "count": 1} for i in range(101)]}, 400, "ITEMS_TOO_MANY"),
]
@pytest.mark.parametrize("case_id,payload,status,code", CASES, ids=[c[0] for c in CASES])
def test_order_params(api, case_id, payload, status, code):
r = api.post("/api/v1/orders", json=payload,
headers={"Idempotency-Key": case_id})
assert r.status_code == status, f"{case_id}: {r.text}"
assert r.json()["code"] == code
2.3 可篡改字段¶
客户端能改的字段,一律假设会被改。以下字段不应出现在请求里,出现也必须被忽略:
| 字段 | 风险 | 验证方式 | 预期 |
|---|---|---|---|
price / payAmount |
1 分钱下单 | 传 price:"0.01" |
按服务端价格计算,返回 payAmount 与商品价一致 |
status |
直接造已支付订单 | 传 status:"PAID" |
忽略或 400,落库仍为 PENDING |
userId |
用他人身份下单 | body 传他人 ID,token 是本人 | 403 或强制以 token 为准 |
orderId |
覆盖已有订单 | 传已存在 ID | 忽略,返回新 ID |
couponDiscount |
自定优惠金额 | 传 "99.00" |
以服务端券规则计算 |
createTime |
伪造时间影响对账 | 传去年时间 | 以服务端时间为准 |
curl -sS -X POST https://api.example.com/api/v1/orders \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
--data-raw '{"userId":1001,"addressId":88,"price":"0.01","status":"PAID",
"items":[{"skuId":1001,"count":2}]}' | jq '.data.payAmount, .data.status'
# 期望 "100.00" 与 "PENDING"
2.4 嵌套与数组¶
- 数组里单元素字段缺失:
items:[{"skuId":1001}]→ 定位到items[0].count,而不是笼统的 400。 - 数组元素为
null:items:[null]→ 400,不能 NPE 变 500。 - 深层嵌套:
items[0].ext传 3 层以上对象 → 若契约未定义,应忽略;定义了就要验 schema。 - 数组顺序:不应影响金额与库存扣减结果。
三、场景级用例¶
3.1 状态机¶
订单状态流转:
| 当前状态 | 允许动作 | 目标状态 | 非法动作 |
|---|---|---|---|
| PENDING | 支付 | PAID | 发货 |
| PENDING | 取消 / 超时 | CANCELLED | 退款 |
| PAID | 发货 | SHIPPED | 再次支付 |
| PAID | 退款 | REFUNDING → REFUNDED | 取消 |
| SHIPPED | 确认收货 | COMPLETED | 取消 |
| COMPLETED | 退款(售后) | REFUNDING | 发货 |
| CANCELLED | — | — | 任何流转 |
每个状态至少三条用例:合法进入、合法退出、非法跳转。非法跳转统一期望 409 CONFLICT + code=ORDER_STATUS_INVALID,并且状态不变。
| ID | 场景 | 预期 | 判定 |
|---|---|---|---|
| S-01 | 正常下单 | 201,PENDING | 库存 -2,券变已使用,订单表 +1 |
| S-02 | 库存 1 买 2 | 400 STOCK_NOT_ENOUGH |
库存仍为 1,无订单记录 |
| S-03 | 商品已下架 | 400 SKU_OFF_SHELF |
不占用库存 |
| S-04 | 支付超时 30 分钟 | CANCELLED | 库存回滚、券退回、有取消原因 |
| S-05 | 已取消订单再次支付 | 409 | 支付流水不新增 |
| S-06 | 已支付订单再次支付 | 409 | 不重复扣款 |
| S-07 | 退款金额 > 实付 | 400 | 不产生负数流水 |
| S-08 | 查询他人订单 | 403/404 | 不泄漏收货地址与手机号 |
3.2 幂等¶
def test_same_key_returns_same_order(api):
key = "7d2c1f0a-9b3e-4c1d-8f2a-0e6b5c4d3a21"
payload = {"userId": 1001, "addressId": 88,
"items": [{"skuId": 1001, "count": 2}]}
first = api.post("/api/v1/orders", json=payload,
headers={"Idempotency-Key": key})
second = api.post("/api/v1/orders", json=payload,
headers={"Idempotency-Key": key})
assert first.status_code == 201
assert second.status_code in (200, 201), second.text
assert first.json()["data"]["orderId"] == second.json()["data"]["orderId"]
assert api.count_orders(client_request_id=key) == 1
判定要点:同一 key + 相同 body 返回同一单;同一 key + 不同 body 应返回 409(防止 key 被复用到别的订单)。只测前者会漏掉“key 冲突但内容不同”的错误实现。
3.3 并发¶
import threading, concurrent.futures as cf
def test_no_oversell(api, sku_with_stock_5):
barrier = threading.Barrier(20)
def place(i):
barrier.wait() # 让 20 个请求尽量同时到达
r = api.post("/api/v1/orders", json={
"userId": 1000 + i, "addressId": 88,
"items": [{"skuId": sku_with_stock_5, "count": 1}]},
headers={"Idempotency-Key": f"conc-{i}"})
return r.status_code
with cf.ThreadPoolExecutor(max_workers=20) as ex:
codes = list(ex.map(place, range(20)))
assert codes.count(201) == 5, codes
assert codes.count(409) == 15, codes
assert api.get(f"/api/v1/skus/{sku_with_stock_5}").json()["data"]["stock"] == 0
并发用例的判定不是“看有没有报错”,而是三个数:成功数 = 库存数、失败数为其余、最终库存 = 0 且不为负。并发用例不要放进每次提交都跑的冒烟集,放在 nightly 或发版前执行,避免污染环境。
3.4 并发领券 / 并发退款¶
def test_coupon_only_once(api, coupon_id):
def grab(i):
return api.post("/api/v1/coupons/claim",
json={"couponId": coupon_id, "userId": 2000 + i}).status_code
with cf.ThreadPoolExecutor(max_workers=50) as ex:
codes = list(ex.map(grab, range(50)))
assert codes.count(200) == 1
assert api.get(f"/api/v1/coupons/{coupon_id}").json()["data"]["remaining"] == 0
退款并发要看账务:同一订单并发两次退款,只允许一条成功,且退款流水合计等于实付金额。
四、异常级用例¶
用 Mock 或故障注入制造下游异常(WireMock 的 fixedDelayMilliseconds 与 fault 见 Mock 一文):
| ID | 注入 | 预期状态码 | 预期业务码 | 额外判定 |
|---|---|---|---|---|
| E-01 | 支付服务延迟 6s(客户端超时 3s) | 504/503 | DOWNSTREAM_TIMEOUT |
不得返回 500 堆栈;订单不得停留在半支付状态 |
| E-02 | 库存服务 500 | 503 | DEPENDENCY_UNAVAILABLE |
有降级或快速失败,响应时间 < 1s |
| E-03 | 库存服务返回脏数据(stock 为 null) | 503/500 | DEPENDENCY_BAD_RESPONSE |
不把 null 当 0 扣减 |
| E-04 | 触发限流 | 429 | RATE_LIMITED |
带 Retry-After,客户端可重试 |
| E-05 | 下游连接被重置 | 503 | DEPENDENCY_UNAVAILABLE |
有重试时必须幂等 |
| E-06 | 数据库主从延迟 | 200/201 | code=0 |
写后立即读要能读到(或明确提示稍后) |
| E-07 | 消息队列不可用 | 201 | code=0 |
本地消息表/补偿存在,订单状态不受阻 |
异常用例的判定重点在“错误是否可归类”。全变成 500 Internal Server Error,前端无法区分“稍后重试”和“参数错了”,这就是不合格。
五、用例表模板¶
| 字段 | 说明 |
|---|---|
| ID | 模块-层级-序号,如 ORD-P-012 |
| 层级 | 参数/结构/业务/异常/非功能 |
| 前置条件 | 可执行的准备:哪个账号、多少库存、券什么状态 |
| 请求 | method + path + 关键 body/header |
| 预期 | 状态码 + 业务码 + 字段值 + 副作用 |
| 判定依据 | 查哪张表、哪个字段、哪条日志 |
| 优先级 | P0/P1/P2 |
| 自动化 | 是/否 + 用例函数名 |
| 备注 | 已知缺陷、环境限制 |
六、JSON Schema 契约校验¶
业务断言管“值对不对”,Schema 管“结构有没有变”。两者都要有:
import jsonschema
ORDER_CREATED = {
"type": "object",
"required": ["code", "data"],
"properties": {
"code": {"const": 0},
"data": {
"type": "object",
"required": ["orderId", "status", "payAmount", "expireAt"],
"properties": {
"orderId": {"type": "integer", "minimum": 1},
"status": {"enum": ["PENDING", "PAID", "CANCELLED"]},
"payAmount": {"type": "string", "pattern": r"^\d+\.\d{2}$"},
"expireAt": {"type": "string",
"pattern": r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+08:00$"},
},
"additionalProperties": False,
},
},
}
def test_create_order_contract(api):
r = api.post("/api/v1/orders", json={...})
jsonschema.validate(instance=r.json(), schema=ORDER_CREATED)
additionalProperties: False 会在服务端偷偷加字段时失败。但新增字段通常不是 bug,所以 Schema 失败要先判断是“破坏性变更”还是“兼容性新增”,别直接当缺陷提。
七、优先级划分¶
| 优先级 | 范围 | 执行时机 |
|---|---|---|
| P0 | 金额、库存、优惠、权限、幂等、支付回调 | 每次提交冒烟 + 发版必跑 |
| P1 | 参数校验、状态流转、下游异常、分页 | 每日回归 |
| P2 | 特殊字符、超长文本、极端并发、兼容性字段 | 发版前或 nightly |
判断标准:错了会导致资损、越权或数据不一致的,一律 P0。参数校验错误只影响提示文案的,放 P1。
八、漏测清单¶
| 漏测点 | 典型表现 | 怎么补 |
|---|---|---|
| 默认值 | 不传 pageSize 时期望 20,实际 0 条 |
断言默认值本身,不只测显式传值 |
| 空数组 vs null | [] 清空成功,null 变成“不修改” |
两个都测,且断言最终数据 |
| 字段名大小写 | userId 与 userid 都能通过(Jackson 配置) |
用严格模式或显式测一次 |
| 编码 | 中文、emoji、+、% 在 query/path/body 各一次 |
断言落库值与原始值逐字符相等 |
| 时区 | 服务器 UTC、用户 +08,expireAt 差 8 小时 |
断言时间戳或带偏移的 ISO 串 |
| 金额精度 | 0.1+0.2=0.30000000000000004 |
用 Decimal/字符串断言 |
| 大整数 | 9007199254740993 在 JS 端变偶数 |
超过 2^53 的 ID 用字符串传输 |
| 分页 total | 翻页时 total 变化导致重复/漏数据 | 固定快照下断言 total 与 ID 集合 |
| 排序稳定性 | 相同 createTime 顺序随机 |
断言排序字段 + 次级排序键 |
| 幂等窗口 | 24 小时后同 key 重新生成订单 | 用可配置窗口的环境验证 |
| 客户端超时重试 | 请求已成功但客户端超时后重试,生成两单 | 服务端幂等键兜底,客户端重试必须带同一 key |
| 响应头 | 漏 Location、Content-Type 无 charset |
单独断言 header |