JSON Schema 契約テスト:バリデーションルールを CI に組み込む
要点:schema は書いて終わりではなく、そこから壊れ始めます。typo 一つでバリデーションが静かに緩み、リファクタリング一つで fixture が全滅します。本記事では正例・負例・スナップショットの三点セット、schema 自体の lint、コンパイル once の再利用、そして CI に据える四つのゲートという実践的な契約テスト構成を解説します。

結論:schema はコードであり、コードにはテストが要ります。正例 fixture は通すべきものを通す証拠、負例 fixture は拒すべきものを拒む証拠、スナップショットは意図しない変更の監視役。最後は ajv compile と --ci スナップショットがパイプラインで夜警を務めます。
バリデーションルールが壊れた瞬間、何もエラーを出しません。緩くなっても誰も気づかず、厳しくなればチーム全員が付き合わされます。schema は設定ファイルではなく、誰もテストしていないコードです。
1. 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 のみで、第 1 節の 2020-12 スキーマに向けると同じ no schema with key or ref "https://json-schema.org/draft/2020-12/schema" を投げます。だから 2020-12 のプロジェクトでは本文と同じ Ajv2020 で lint し、npx ajv-cli compile -s '**/*.schema.json' --strict は draft-07 専用です。
lint のつもりで回せば、typo・非推奨キーワード・未登録 format はすべて PR 時点で姿を現します。これが schema テストと普通のコードテストの最初の共通点です。まずコンパイルが通ることを確認する。
2. 三点セット:正例・負例・スナップショット
schema テストの骨格は三つだけです。
正例(valid fixtures):実際のレスポンスから採取した、通過すべきサンプル。記憶で手書きせず staging から取ること——手書きの正例は happy path しかカバーせず、null や空文字列、必須のみの境界を見落としがちです。
負例(invalid fixtures):schema 内のすべての制約について、最低一つの違反入力を用意します。minimum: 0 なら -1(拒否)と 0(通過)のペア、enum ならリスト外の値、additionalProperties: false なら余分なフィールド。負例の方が正例より重要です。schema が静かに悪いデータを通すのはサイレントバグで、そのままデータベースまで汚染します。
ファイル名をアサーションにする命名規約はオープンソースからそのまま借りられます:
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 で十分です:
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 をシリアライズして保存・比較します。誰かが意図せず制約を変えたら、スナップショットが即座に赤くなります。Jest/Vitest なら expect(errors).toMatchSnapshot() の一行。CI では必ず --ci を付けてください——新規スナップショットが自動作成でなく失敗になるため、ドリフトが黙認されません。
3. エラーを一度で全部集める
負例テストで見落とされがちな設定:デフォルトではバリデータは最初のエラーで停止します。データが三箇所で壊れていても見えるのは一箇所で、直しては再実行の繰り返しです。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 が必要です。
4. 一度コンパイルしたら、コンパイラを使い捨てにしない
ローカル実測(Node v22.13.1、Ajv 8.17、同じ三層ネスト schema・同じ違反データ):
| 書き方 | 1 万回の検証時間 |
|---|---|
毎回 new Ajv() して compile |
42233 ms |
モジュール級インスタンス + compile 一回の再利用 |
6.9 ms |
6000 倍の差です。検証関数をリクエストハンドラ内に置き、呼び出しごとに新しい Ajv インスタンスを作るのは最も多い性能事故です。広く流布している誤解「ajv.validate(schema, data) は毎回再コンパイルする」にも注意——実測では 10 万回 8.2ms で、再利用した compile の 11.2ms と同等でした。Ajv は内部に schema キャッシュを持つためです。本当の問題はキャッシュミスではなくインスタンスを再利用していないことです。Python も同様で、Draft202012Validator の再利用は都度 validate() を呼ぶより約 5000 倍速い(2000 回で 0.3ms vs 1566ms)。後者は毎回 schema 自体を検査するためです。
5. ゲートを CI に据える:四つの扉
コストの安い順に並べます。前が落ちたら後ろは走りません:
| ゲート | コマンド形態 | 検知対象 | コスト |
|---|---|---|---|
| schema lint | node scripts/lint-schemas.mjs |
schema の構文エラー・非推奨キーワード | ミリ秒 |
| fixture 検証 | セクション2のテストスイート | fixture と schema の不一致・制約の誤変更 | ミリ秒 |
| 破壊的変更 diff | json-schema-diff-validator で main と比較 |
required 削除・enum 縮小・pattern 強化 | 秒単位 |
| live レスポンス検証 | staging に smoke テスト、.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 # スナップショットドリフト防止
破壊的変更ゲートは特に強調に値します。json-schema-diff-validator 系ツールは「required フィールドの削除」「enum からの値消失」をレビュー時に赤字にします。これは平易な API diff ツールが「非破壊」と判定しがちな二種類の変更です。寛容なコンシューマには無害でも、exhaustive switch を持つコンシューマには本番クラッシュです。
6. 真偽値ではなくエラーオブジェクトを表明する
負例テストで validate(data) === false だけの表明は不十分です。同じ schema でも、正しい理由で拒んでいるかを確認すべきです。無関係な制約が偶然通っている可能性があるからです。Ajv のエラーオブジェクトには有名な非対称があります。required 失敗時、instancePath は親オブジェクトを指し、欠落フィールド名は params.missingProperty に隠れます。実測で id 欠落時に得られたのは:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
表明は errors.length > 0 ではなく「keyword=required かつ missingProperty=id のエラーが存在する」と書きます。エラーフィールドの意味の詳細は JSON Schema のエラーが読めない:Ajv エラー特定とメッセージ改善 を参照してください。
7. よくある落とし穴速査表
| 落とし穴 | 結果 | 対策 |
|---|---|---|
| 200 happy path しか測らない | エラー envelop のドリフトに気づかない | エラーレスポンスにも schema と fixture を |
| 生成ツールの出力を無審査 | 単一サンプルから推測された required が実データを誤拒否し、チームは検証を切る | 生成後に required/enum を人間が確認 |
| 全バージョンで schema 一つ | v1/v2 の差分が消える | schema はエンドポイントと共にバージョン管理 |
| 失敗時にペイロード全文をログへ | PII と秘密鍵が CI ログに漏える | JSON Pointer パスと切り詰めた断片のみ |
| enum の固定 | 用語変更(canceled→cancelled)で検証が壊れる | enum 変更は互換性レビューへ |
| バリデータ版の固定漏れ | マシン間で挙動が不一致 | CI でバリデータの版を固定 |
| 権限テストの代替にする | 403 がエラー schema に合致してもロール判定は誤り | schema は構造、認可は別途テスト |
| format の意味を忘れる | 壊れたメールアドレスが素通し | ajv-formats 導入 / format_checker 渡し、負例も用意 |
8. 導入チェックリスト
- schema ファイルをバージョン管理に入れ、
node scripts/lint-schemas.mjsを lint に; - 各 schema に正例 1 件以上 + 制約ごとに負例 1 件、ファイル名がアサーション;
- エラーオブジェクトをスナップショット化し、CI では
--ciで自動ドリフトを防ぐ; - バリデータインスタンスはモジュール級で再利用、ハンドラ内で new しない;
- 破壊的変更 diff を PR に掛け、required/enum の変更は強制レビュー;
- 最初の PR でゲートが失敗できることを証明:required を一つ削り、赤くなるのを見てから戻す。
検証の緩急(いつ additionalProperties を閉じるか)は別の論点です。additionalProperties を閉じないと Schema 検証は無意味 を参照。本記事の主題は、書かれたルールを継続的に信頼できる状態に保つことです。基礎から固めるなら JSON Schema 入門から実務へ からどうぞ。
FAQ
schema 検証と Pact 型契約テストの関係は?
役割分担です。schema 検証は「この文書は構造的に正しいか」という一方向の表明。Pact のようなコンシューマ駆動契約テストは「プロバイダはまだコンシューマ X の具体的な需要を満たすか」という双方向の検証です。小規模チームは schema ゲートから始め、サービス数が増えたら Pact を採用します。
負例テストは何件書けば十分か?
制約一つに負例一件が底線です。required・type・minimum・enum・pattern・additionalProperties など制約の数だけ負例があり、さらに境界値の一拒一收を加えます。制約数に満たなければ、どこかに監視されていない制約が残っています。
schema 変更のうち何が「破壊的」か?
コンシューマ視点で判断します。レスポンスフィールドの削除、enum の削除・縮小、pattern の強化、必須リクエストフィールドの追加はすべて破壊的。オプションのレスポンスフィールド追加は通常無害ですが、コンシューマのデコーダが未知のキーを許容するのが前提です。json-schema-diff-validator で PR 時に自動比較し、グレーゾーンは人間が裁決します。
テストは実 API と mock のどちらを打つべき?
両方、ただし分層で。fixture テストはネットワークに触れずミリ秒単位、毎回の PR で実行。live 検証は staging に打ち秒単位、smoke パイプラインに属し、認証情報がなければ skipif で省略します。CI で実 API だけ回すチームはネットワークの揺らぎとレート制限に消耗し、最後にはテストを切ります。