JSON 工具箱

JSON Schema 契约测试:把校验规则焊进 CI

结论:schema 写完不改就是坏的起点:一个 typo 让校验器静默放行,一次重构让 fixture 全线误报。本文给出一套可落地的契约测试方案——正例、负例、快照三件套,schema 本身先进 lint,编译一次复用,再把四道闸门焊进 CI,让契约坏了构建先红。

实战约 8 分钟发布于 2026-11-05更新于 2026-11-05
JSON Schema 契约测试与 CI 校验门禁示意图

结论:schema 本身就是代码,代码要有测试——正例 fixture 证明它放行该放的,负例 fixture 证明它拦住该拦的,快照盯住它的无意变更,最后让 ajv compile 和 --ci 快照在流水线里替你守夜。

校验规则坏掉的那一刻没有任何报错:放行变宽了没人知道,拦住变严了全团队陪跑。schema 不是配置文件,是没人测过的代码。

一、schema 坏了,谁先知道

先看一个真实撞上的坑。把 schema 声明成 2020-12 草案,用 Ajv 默认包编译,直接抛错:

Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"

Ajv 8 的默认入口只认 draft-07,2020-12 要从 ajv/dist/2020 导入。换个坑:strict 模式下写了 format: "email" 但没装 ajv-formats,编译同样直接抛:

Error: unknown format "email" ignored in schema at path "#/properties/email"

这两个报错如果在本地第一次编译时就能看到,是好事;问题是大量项目把编译推迟到请求进来时,坏 schema 要等第一个用户触发。更隐蔽的是语法半坏的 schema——required 手滑写成了字符串而不是数组,编译抛 schema is invalid: data/required must be array,但如果这一步从未在 CI 里跑过,它就埋在生产里。

所以第一道门不是校验数据,是校验 schema 自己。一行命令:

node scripts/lint-schemas.mjs
// scripts/lint-schemas.mjs
import { readFileSync, readdirSync } from 'node:fs';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';

const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
for (const file of readdirSync('schemas')) {
  const schema = JSON.parse(readFileSync(`schemas/${file}`, 'utf8'));
  ajv.compile(schema);
  console.log(`ok ${file}`);
}

为什么不是 npx ajv compile:ajv v8 包不带 CLI,npx ajv 会报 could not determine executable to run;CLI 在独立的 ajv-cli 包(本机实测 5.0.0)。但 ajv-cli 5 只支持 draft-07 与 draft-2019-09,指向第一节的 2020-12 schema 时,会原样抛出 no schema with key or ref "https://json-schema.org/draft/2020-12/schema"。所以 2020-12 项目的 lint 直接用与正文相同的 Ajv2020 入口;npx ajv-cli compile -s '**/*.schema.json' --strict 只适用于 draft-07 项目。

把它当 lint 跑,typo、废弃关键字、未注册的 format 全部在 PR 阶段现形。这也是 schema 与普通代码测试的第一个共同点:先确认它能编译。

二、三件套:正例、负例、快照

schema 测试的骨架就三样东西。

正例(valid fixtures):从真实响应里抓的、应该通过的样本。注意要从 staging 抓,不要凭记忆手写——手写的正例往往只覆盖 happy path,漏掉 null、空串、只有必填字段的边界。

负例(invalid fixtures):对 schema 里每一条约束至少给一个违规输入。minimum: 0 就测 -1 和 0 一拒一收;enum 就测一个列表外的值;additionalProperties: false 就测一个多余字段。负例比正例重要:schema 意外放行坏数据是静默 bug,会一路污染到数据库。

命名约定可以直接抄开源项目的做法——文件名即断言:

schemas/order.schema.json
fixtures/order/valid-minimal.json        # 必须通过
fixtures/order/valid-full.json           # 必须通过
fixtures/order/invalid-type.json         # 必须被拒
fixtures/order/invalid-extra-field.json  # 必须被拒

测试框架用 Node 自带的 node:test 也够,不必绑 Jest:

import { test } from 'node:test';
import { readFileSync, readdirSync } from 'node:fs';
import assert from 'node:assert/strict';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';

const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validate = ajv.compile(
  JSON.parse(readFileSync('schemas/order.schema.json', 'utf8'))
);

for (const f of readdirSync('fixtures/order')) {
  const data = JSON.parse(readFileSync(`fixtures/order/${f}`, 'utf8'));
  if (f.startsWith('valid-')) {
    test(`${f} 应通过`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
  } else {
    test(`${f} 应被拒`, () => { assert.ok(!validate(data)); });
  }
}

快照:把 validate.errors 序列化后存档比对。schema 被人无意改了约束,快照立刻红。Jest/Vitest 里一行 expect(errors).toMatchSnapshot(),CI 里务必加 --ci 参数——新模式快照直接失败而不是自动创建,防止漂移被悄悄接受。

三、一次收齐所有错误

测试负例时有个容易忽略的配置:默认情况下校验器碰到第一个错误就停。数据坏了三处,你只能看到第一处,改完重跑再看第二处。加上 allErrors: true(Ajv)或改用 iter_errors(Python),一次看全:

from jsonschema import Draft202012Validator

validator = Draft202012Validator(schema)
errors = sorted(validator.iter_errors(bad), key=lambda e: list(e.absolute_path))
for e in errors:
    print(list(e.absolute_path), e.validator, e.message)

Python 侧还有一个反直觉的事实:validate() 报哪条不是「第一条」而是 best_match 挑的——我们的实测里 id 类型错、role 枚举错、多出 extra 字段三处违规,它只报了 additionalProperties 那条,id 的类型错误被藏起来了。CI 里要看全量,永远用 iter_errors。

还有一条更容易踩的:format 默认不是断言。2020-12 把 format 定位为注解,Python 的 jsonschema 不传 format_checker 就不校验,实测 "not-an-email" 畅通无阻;Ajv 则要装 ajv-formats 并注册。format 关键的数据(邮箱、日期、uuid)在负例里必须有对应 fixture,证明你的校验器配置真的在校验它。

四、编译一次,别把编译器当一次性工具

本机实测(Node v22.13.1,Ajv 8.17,同一份三层嵌套 schema、同一条违规数据):

写法 1 万次校验耗时
每次 new Ajv() 再 compile 42233 ms
模块级实例 + compile 一次复用 6.9 ms

差了 6000 倍。把校验函数写在请求 handler 里、每次调用都新建 Ajv 实例,是最常见的性能事故。另外注意一个流传甚广的误解:「ajv.validate(schema, data) 每次都会重新编译」——实测 10 万次 8.2ms,与复用 compile 的 11.2ms 相当,因为 Ajv 内部有 schema 缓存。真正的坑不是缓存没命中,是实例没复用。Python 侧同理:复用 Draft202012Validator 每次调 validate() 快 5000 倍(2000 次对比 0.3ms vs 1566ms),因为后者每次都要先检查 schema 本身。

五、把门禁焊进 CI:四道闸

按成本从低到高排,前面的挂了后面的不用跑:

闸门 命令形态 拦什么 耗时量级
schema lint node scripts/lint-schemas.mjs schema 语法错、废弃关键字 毫秒
fixture 校验 上面第二节的测试套件 正负例与 schema 不匹配、约束被误改 毫秒
破坏性变更 diff json-schema-diff-validator 对比 main 删 required、收窄 enum、收紧 pattern 秒级
live 响应校验 smoke 测试打 staging,断言 .toMatchSchema() 真实接口漂移 秒级

GitHub Actions 里最小配置:

- run: npm ci
- run: npm run lint:schemas        # Ajv2020 lint
- run: npm run test:schema         # fixture 三件套
- run: npx jest --ci --testPathPattern=schema   # 快照防漂移

破坏性变更 gate 值得单独强调:json-schema-diff-validator 这类工具在 PR 评审阶段就把「删 required 字段」「枚举少了一个值」标红,而这两类变更恰恰是 API diff 工具常判为「非破坏」的——响应里删字段对宽松消费者无害,对 exhaustive switch 的消费者是线上崩溃。

六、断言错误对象,而不只是布尔值

负例测试只断言 validate(data) === false 还不够。同一个 schema,你至少要确认它拒的理由是对的,否则可能因为一个无关约束碰巧通过。Ajv 的错误对象里有个著名的不对称:required 失败时 instancePath 指向父对象,缺失的字段名藏在 params.missingProperty——实测缺 id 字段时错误对象是:

{ "instancePath": "", "schemaPath": "#/required",
  "keyword": "required", "params": { "missingProperty": "id" } }

断言就写成「存在一条 keyword=required 且 missingProperty=id 的错误」,而不是笼统的 errors.length > 0。错误字段语义的完整拆解见《JSON Schema 报错看不懂:Ajv 错误定位与错误信息治理》。

七、常见坑速查

坑 后果 对策
只测 200 happy path 错误信封漂移无人知 错误响应也建 schema、配 fixture
生成器产出不审 从单样本推出的 required 误拒真实数据,团队干脆关掉校验 生成后人工过一遍 required/enum
schema 全局一份服务多版本 v1/v2 差异被抹平 schema 与端点一起版本化
失败时打全量载荷进日志 PII 与密钥泄进 CI 日志 只打 JSON Pointer 路径与截断片段
枚举写死 产品改词(canceled→cancelled)校验挂 枚举变更走兼容性评审
校验器版本不固定 不同机器行为不一致 CI 里锁定校验器版本
替代权限测试 403 响应符合错误 schema 但角色逻辑错 schema 管结构,鉴权单独测
忘了 format 语义 邮箱乱码畅通无阻 装 ajv-formats / 传 format_checker,并配负例

八、落地清单

  1. schema 文件进版本库,node scripts/lint-schemas.mjs 进 lint;
  2. 每个 schema 至少 1 个正例 + 每条约束 1 个负例,文件名即断言;
  3. 错误对象快照化,CI 用 --ci 参数防止快照自动漂移;
  4. 校验器实例模块级复用,别在 handler 里 new;
  5. 破坏性变更 diff 挂在 PR 上,required/enum 变更强制评审;
  6. 第一条 PR 专门验证 gate 能失败:删一个 required 字段,看到红色再还原。

schema 校验的松紧取舍(什么时候该关 additionalProperties)是另一件事,见《additionalProperties 不关,Schema 校验等于没校验》;本文解决的是让写好的规则持续可信。想从零补基础,从《JSON Schema 从入门到工程化》开始。

FAQ

schema 校验和 Pact 契约测试是什么关系?

分工不同。schema 校验回答「这份文档结构对不对」,是单向断言;Pact 这类消费者驱动契约测试回答「provider 还满不满足消费者 X 的具体需要」,需要双向验证。小团队先用 schema gate 起步,服务数量上来后再引入 Pact。

负例测试要写多少条才够?

一条约束一条负例是底线:schema 里有几条约束(required、type、minimum、enum、pattern、additionalProperties……)就至少几条负例,再加边界值一拒一收。少于约束数一定有约束没被测试盯住。

schema 变更怎么才算「破坏性」的?

对消费者视角判断:删响应字段、删或收窄枚举、收紧 pattern、新增必填入参都是;加可选响应字段通常无害——但前提是消费者的解码器容忍未知键。用 json-schema-diff-validator 在 PR 阶段自动比对,人工裁决灰色地带。

测试该打真实接口还是 mock?

都要,但分层:fixture 测试不碰网络、毫秒级、进每次 PR;live 校验打 staging、秒级、进 smoke 流程,密钥缺失时用 skipif 跳过。只在 CI 里跑真实接口的团队,会被网络抖动和限流磨掉耐心,最后把测试关掉。

参考资料

相关文章

JSON Schema 报错看不懂:Ajv 错误定位与错误信息治理

校验器给出的是一串机器错误,用户需要的是「哪个输入框错了」。本文拆解 Ajv 的 errors 数组,讲清 instancePath 与 schemaPath 的差别,并给出归类、自定义文案、国际化与前端呈现的完整方案。

实战约 10 分钟更新于 2026-10-31

additionalProperties 不关,Schema 校验等于没校验

校验通过不代表数据干净。Pydantic 默认忽略未知字段,Zod 默认剥离,Ajv 默认放行——模型编造的参数、拼错的键都会静默穿过。本文用实测说明静默放行的三种形态,并给出关闭对象、控制强转、逐层收口的完整写法。

进阶约 8 分钟更新于 2026-11-02

JSON Schema 从入门到工程化

关键字很多,真正天天用的只有十几个。把 type/properties/required/$ref 用对,再处理好错误信息,就能覆盖八成场景。

进阶约 14 分钟更新于 2026-10-30