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