跳转至

接口自动化断言与 JSON-Schema 校验

一、一次“全绿”的上线事故

订单列表接口改版后,回归用例 38 条全部通过。上线当天前端白屏,报错:

TypeError: Cannot read properties of null (reading 'map')

查下来响应是 {"code":0,"data":null},而用例写的是:

def test_list(resp):
    assert resp.status_code == 200
    assert resp.json()["code"] == 0

两条断言都成立,但 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) == 期望条数。

十一、空值不等于字段缺失

assert "couponId" in data            # 字段存在(可能是 null)
assert data["couponId"] is None      # 明确为空

这两句语义完全不同。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,提供方新增一个可选字段,校验直接失败。

兼容策略:

  1. 只做加法:先加可选字段,观察一个发布周期再考虑必填。
  2. 消费方默认允许未知字段,除非有强契约要求;需要严格时用独立版本。
  3. 破坏性变更走新版本路径(/api/v2/orders),旧版本保留至少一个弃用周期。
  4. 契约文件进 CI:消费方生成、提供方验证,任何一边改字段都会在合并前暴露。
  5. 记录 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
失败信息 断言带响应体或关键字段

相关阅读