JSON Schema 契約測試:把校驗規則焊進 CI
結論:schema 寫完不改就是壞的起點:一個 typo 讓校驗器靜默放行,一次重構讓 fixture 全線誤報。本文給出一套可落地的契約測試方案——正例、負例、快照三件套,schema 本身先進 lint,編譯一次複用,再把四道閘門焊進 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,並配負例 |
八、落地清單
- schema 檔案進版本庫,
node scripts/lint-schemas.mjs進 lint; - 每個 schema 至少 1 個正例 + 每條約束 1 個負例,檔名即斷言;
- 錯誤物件快照化,CI 用
--ci參數防止快照自動漂移; - 校驗器實例模組級複用,別在 handler 裡 new;
- 破壞性變更 diff 掛在 PR 上,required/enum 變更強制審查;
- 第一條 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 裡跑真實介面的團隊,會被網路抖動和限流磨掉耐心,最後把測試關掉。