跳转至

接口测试用例设计:参数、场景与异常

“接口通了”不等于“接口对了”。一条用例如果没有明确的判定依据(看哪个字段、看哪张表、看哪条消息),执行结果就只能靠感觉。下面用一个真实形态的下单接口,从参数到状态机逐条给出输入、预期和判定点。

一、被测接口与判定基线

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

相关阅读