接口自动化断言与 JSON-Schema 校验¶
一、一次“全绿”的上线事故¶
订单列表接口改版后,回归用例 38 条全部通过。上线当天前端白屏,报错:
查下来响应是 {"code":0,"data":null},而用例写的是:
两条断言都成立,但 data 为 null 时业务已经不可用。同一周还出现金额从 "100.00" 变成 100(数字类型),旧用例 assert data["amount"] == "100.00" 直接失败,开发却认为“值没变”。
根因只有一句话:断言停在了协议层,没有往业务和数据层走。
二、断言分层¶
| 层级 | 断言内容 | 失败意味着 | 示例 |
|---|---|---|---|
| 协议层 | 状态码、Content-Type、Header | 请求处理异常 | status_code == 201 |
| 业务层 | 业务 code、message 语义 | 业务未成功 | code == 0 |
| 字段层 | 存在性、类型、格式、范围 | 契约破裂 | orderId 是 int 且 >0 |
| 结构层 | JSON Schema | 结构漂移、字段缺失 | required: [orderId, status] |
| 最终一致 | 数据库、MQ、缓存 | 返回成功但没落库 | 查 orders.status |
只用第一层就是“假通过”;只加第二层仍会放过 data: null。正确姿势是逐层加,且每层都能独立定位。
三、JSON Schema 各 Draft 的差异¶
不同版本的关键字行为不同,混用会写出“看起来校验了、其实没生效”的 Schema:
| 特性 | Draft-04 | Draft-07 | 2019-09 | 2020-12 |
|---|---|---|---|---|
$schema |
http://json-schema.org/draft-04/schema# |
...draft-07... |
https://json-schema.org/draft/2019-09/schema |
https://json-schema.org/draft/2020-12/schema |
exclusiveMinimum |
布尔值,配合 minimum |
数值 | 数值 | 数值 |
| 定义复用 | definitions |
definitions |
$defs |
$defs |
| 数组元组 | items: [] |
items: [] |
items: [] |
prefixItems + items |
| 条件 | 无 | if/then/else |
同左 | 同左 |
| 依赖 | dependencies |
dependencies |
dependentRequired / dependentSchemas |
同左 |
| 未声明属性 | - | - | unevaluatedProperties |
unevaluatedProperties |
两个容易踩的点:Draft-04 里写 "exclusiveMinimum": 0 是无效关键字(它要布尔值);2020-12 里用 items: [{...}] 表达元组不会报错,但语义不是“第一个元素”,应改用 prefixItems。Python 库必须显式选择校验器,别依赖默认值。
四、一份完整的 2020-12 Schema¶
ORDER_SCHEMA = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["code", "data"],
"additionalProperties": False,
"properties": {
"code": {"type": "integer", "enum": [0, 40001, 40021]},
"message": {"type": "string", "maxLength": 200},
"data": {
"type": "object",
"required": ["orderId", "status", "amount", "items"],
"properties": {
"orderId": {"type": "integer", "minimum": 1},
"status": {"enum": ["PENDING", "PAID", "CANCELLED"]},
"amount": {
"type": "string",
"pattern": r"^\d{1,10}\.\d{2}$",
},
"createdAt": {"type": "string", "format": "date-time"},
"couponId": {"type": ["integer", "null"]},
"items": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"required": ["skuId", "qty", "price"],
"properties": {
"skuId": {"type": "integer"},
"qty": {"type": "integer", "minimum": 1},
"price": {"type": "string", "pattern": r"^\d+\.\d{2}$"},
},
"additionalProperties": False,
},
},
},
"additionalProperties": False,
},
},
}
金额用字符串固定两位小数,是为了绕开 IEEE-754:0.1 + 0.2 在 JS 里等于 0.30000000000000004,用 number 传金额迟早出事。createdAt 用 format: date-time 时要注意,jsonschema 默认不校验 format,必须传 format_checker,否则这一行等于没写。
五、Python 校验代码与错误格式化¶
from jsonschema import Draft202012Validator, FormatChecker
VALIDATOR = Draft202012Validator(ORDER_SCHEMA, format_checker=FormatChecker())
def assert_schema(body: dict) -> None:
errors = sorted(VALIDATOR.iter_errors(body), key=lambda e: e.json_path)
if not errors:
return
lines = []
for err in errors:
# json_path 需要 jsonschema >= 4.18,输出形如 $.data.items[0].qty
lines.append(f"- {err.json_path}: {err.message}")
raise AssertionError("响应不符合 Schema:\n" + "\n".join(lines))
失败输出直接可定位:
AssertionError: 响应不符合 Schema:
- $.data.amount: '100' does not match '^\\d{1,10}\\.\\d{2}$'
- $.data.items[0].qty: 0 is less than the minimum of 1
- $.data: 'orderId' is a required property
配套测试:
def test_order_schema(resp):
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["code"] == 0, body
assert_schema(body)
第一个断言必须带 resp.text,否则 CI 里只见 200 != 500,看不到服务端真实错误。
六、响应时间断言¶
def test_list_latency(session):
session.get("/api/v1/orders?page=1&size=20") # 预热:建连、TLS、连接池
resp = session.get("/api/v1/orders?page=1&size=20")
cost = resp.elapsed.total_seconds()
assert resp.status_code == 200, resp.text
assert cost < 0.8, f"列表接口耗时 {cost:.3f}s,超过 0.8s 阈值"
requests 的 elapsed 包含建连、TLS 握手、发送与读取响应体,不包含 DNS 之外的连接池等待;首次请求常因 TLS 握手多 100~300ms,所以要先预热。单次断言只能防“明显退化”,P95/P99 要在压测里看,别把 0.8s 硬编码成 SLA。
七、契约校验(Pact)¶
流程是消费方先写期望、生成契约文件、提供方在 CI 里回放验证:
# consumer 侧,pact-python 1.6.x
from pact import Consumer, Provider
pact = Consumer("order-web").has_pact_with(
Provider("order-service"), pact_dir="./pacts", port=1234
)
pact.start_service()
(pact
.given("订单 1001 存在")
.upon_receiving("查询订单详情")
.with_request("get", "/api/v1/orders/1001")
.will_respond_with(200, body={"code": 0, "data": {"orderId": 1001, "status": "PAID"}}))
with pact:
result = requests.get("http://localhost:1234/api/v1/orders/1001")
pact.stop_service()
# provider 侧验证,失败会列出不匹配字段
pact-verifier --provider-base-url=http://localhost:8080 \
--pact-url=./pacts/order-web-order-service.json \
--provider-states-setup-url=http://localhost:8080/_pact/state
# 输出:1 interaction, 1 failure
# expected 200 but was 500
Pact 校验的是“双方约定的字段存在且类型一致”,不校验具体业务值;断言具体值仍归用例层。轻量替代是把 OpenAPI 作为契约,用 schemathesis 或 openapi-spec-validator 生成基础用例。
八、软断言:一次收集多个失败¶
from pytest_check import check
def test_create_order(resp):
check.equal(resp.status_code, 201, resp.text)
body = resp.json()
check.equal(body["code"], 0)
check.is_in("orderId", body.get("data") or {})
check.equal(body.get("data", {}).get("status"), "PENDING")
硬断言在第一处失败就停,表单类接口要跑 4 次才能集齐问题;软断言的代价是失败后仍继续执行,若后续步骤依赖前面结果,会制造级联报错。规则:只在校验同一响应的多个独立字段时用,跨接口流程仍用硬断言。
九、金额必须用 Decimal¶
from decimal import Decimal
amount = body["data"]["amount"]
assert Decimal(str(amount)).quantize(Decimal("0.01")) == Decimal("100.00"), amount
assert Decimal(str(amount)) == Decimal("100.00")
Decimal(100.0) 会把浮点误差带进来(Decimal(0.1) 是 0.1000000000000000055511151231257827),必须 Decimal(str(x))。也不要用 abs(a - b) < 0.01 代替精度断言,那会放过 0.99 与 1.00 这类真实差异。
十、数组顺序与集合比较¶
接口没有承诺顺序时,断言顺序就是自找不稳定:
# 错误:依赖数据库返回顺序
assert [u["id"] for u in data["list"]] == [3, 1, 2]
# 正确:集合比较
assert {u["id"] for u in data["list"]} == {1, 2, 3}
# 有重复元素时用 Counter
from collections import Counter
assert Counter(x["status"] for x in data["list"]) == Counter({"PAID": 2, "PENDING": 1})
# 接口明确按 createdAt 倒序时才断言顺序
times = [x["createdAt"] for x in data["list"]]
assert times == sorted(times, reverse=True)
集合比较会丢掉“重复返回同一条”的问题,所以列表类接口要同时断言 len(list) == 期望条数。
十一、空值不等于字段缺失¶
这两句语义完全不同。Schema 里 required 只管“键在不在”,{"type": "integer"} 会拒绝 null;要允许空值必须写 {"type": ["integer", "null"]}。很多“Schema 校验通过但前端崩”的问题,就是 required 写了、null 没禁。
十二、Schema 演进:新增必填字段为什么会打挂旧用例¶
提供方在响应里加了 "couponId" 并设为必填,消费方旧用例立刻报:
jsonschema.exceptions.ValidationError: 'couponId' is a required property
Failed validating 'required' in schema['properties']['data']
反方向也会炸:消费方 Schema 写了 "additionalProperties": false,提供方新增一个可选字段,校验直接失败。
兼容策略:
- 只做加法:先加可选字段,观察一个发布周期再考虑必填。
- 消费方默认允许未知字段,除非有强契约要求;需要严格时用独立版本。
- 破坏性变更走新版本路径(
/api/v2/orders),旧版本保留至少一个弃用周期。 - 契约文件进 CI:消费方生成、提供方验证,任何一边改字段都会在合并前暴露。
- 记录 Schema 版本(响应头
X-Schema-Version或字段schemaVersion),出错时能定位是哪一版。
十三、四个最常见的假通过¶
| 写法 | 为什么假 | 正确做法 |
|---|---|---|
只断言 status_code == 200 |
业务失败常也返回 200 | 加业务码与 data 非空 |
只断言 code == 0 |
data: null 同样通过 |
字段存在性 + Schema |
| 忽略编码 | text/plain 无 charset 时 requests 按 ISO-8859-1 解码,中文变乱码 |
resp.content.decode("utf-8") 或先设 resp.encoding |
| 忽略类型 | True == 1、1 == 1.0 在 Python 中为真;JS 大整数丢精度 |
isinstance + Schema type |
编码问题的真实表现:
>>> resp.headers["Content-Type"]
'text/plain'
>>> resp.text
'æ\x93\x8dä½\x9c失败' # 乱码
>>> resp.content.decode("utf-8")
'操作失败'
大整数精度问题:
curl -s https://demo.example.com/api/v1/orders/9007199254740993 | jq '.data.orderId'
# 9007199254740992,末位被 JS 引擎改写;接口应返回字符串
十四、断言检查清单¶
| 检查项 | 判定标准 |
|---|---|
| 状态码 | 成功、参数错、未认证、无权限、不存在各有对应用例 |
| 业务码 | 成功码与至少 3 个失败码被覆盖 |
| data 非空 | 成功响应断言 data is not None |
| 字段类型 | 用 Schema 校验,不靠 == |
| 金额 | Decimal + 两位小数 |
| 列表 | 长度 + 集合,必要时才断言顺序 |
| 空值 | 区分“缺失”与“为 null” |
| 时间 | 校验格式与区间,不断言具体值 |
| 最终一致 | 核心写接口查库/MQ |
| 失败信息 | 断言带响应体或关键字段 |