تست قرارداد JSON Schema: قواعد اعتبارسنجی را در CI جوش دهید
جمعبندی: اسکیما با نوشتن تمام نمیشود — از همانجا زوال آغاز میشود. یک غلط تایپی بیصدا اعتبارسنجی را شل میکند، یک ریفکتور همه فیکسچرها را میشکند. این راهنما ساختاری عملی برای تست قرارداد ارائه میدهد: فیکسچرهای مثبت، منفی و اسنپشات، لینت خودِ اسکیما، کامپایل یکباره و استفاده مجدد، و چهار دروازه CI که شکست قرارداد را به بیلد قرمز تبدیل میکند.

نتیجه: اسکیما یک کد است و کد به تست نیاز دارد. فیکسچرهای مثبت ثابت میکنند آنچه را که باید عبور دهد، عبور میدهد؛ منفیها ثابت میکنند آنچه را که باید بلاک کند، بلاک میکند؛ اسنپشاتها تغییرات ناخواسته را زیر نظر دارند. سرانجام ajv compile و اسنپشاتهای --ci نگهبان شبانه پایپلاین شما هستند.
در لحظهای که قانون اعتبارسنجی میشکند، هیچ خطایی پرتاب نمیشود: گشترش پذیرش کسی را آگاه نمیکند، سختگیری رد کردن را کل تیم متحمل میشود. اسکیما فایل پیکربندی نیست — کدی است که هیچکس تستش نمیکند.
۱. اسکیما شکست — چه کسی اول میفهمد
با دامی که واقعاً گرفتارش شدیم شروع کنیم. اسکیما را بر پایه پیشنویس ۲۰۲۰-۱۲ اعلان کنید و با پکیج پیشفرض Ajv کامپایل کنید: فوراً یک استثنا پرتاب میشود:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
نقطه ورود پیشفرض Ajv 8 فقط draft-07 را میشناسد؛ برای ۲۰۲۰-۱۲ باید از ajv/dist/2020 ایمپورت کنید. دام دیگر: در حالت strict، نوشتن format: "email" بدون نصب ajv-formats هم کامپایل را میاندازد:
Error: unknown format "email" ignored in schema at path "#/properties/email"
دیدن این خطاها در اولین کامپایلِ لوکال خوب است. مشکل: بسیاری پروژهها کامپایل را تا رسیدن ریکوئست عقب میاندازند و اسکیمای شکسته منتظر قربانی اول میماند. پنهانتر اسکیمای نیمهشکسته است: required که سهواً رشته نوشته شده نه آرایه، در کامپایل schema is invalid: data/required must be array میپراند — اما اگر آن گام هرگز در CI اجرا نشده باشد، در پروداکشن خوابیده است.
پس نخستین دروازه دادهها را اعتبارسنجی نمیکند: خودِ اسکیما را اعتبارسنجی میکند. یک فرمان:
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 نسخهٔ 8 شامل 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 بخش نخست، دقیقاً همان 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 درست است.
مثل لینتر اجرایش کنید: غلطهای تایپی، کلیدواژههای منسوخ و فرمتهای ثبتنشده همان در PR ظاهر میشوند. این نخستین شباهت تست اسکیما به تست کد معمولی است: اول مطمئن شوید کامپایل میشود.
۲. سهگانه: مثبت، منفی، اسنپشات
اسکلت تست اسکیما سه بخش دارد.
فیکسچرهای مثبت: نمونههای برداشته از پاسخهای واقعی که باید عبور کنند. از حافظه ننویسید — از staging بگیرید؛ مثبتهای دستی فقط مسیر خوشحال را پوشش میدهند و null، رشتههای خالی و مرزهای فقط-اجباری را فراموش میکنند.
فیکسچرهای منفی: برای هر قید اسکیما، دستکم یک ورودی نقضگر. برای minimum: 0 جفت -1 (رد) و 0 (عبور)؛ برای enum مقداری بیرون فهرست؛ برای additionalProperties: false یک فیلد زائد. منفیها از مثبتها مهمترند: اسکیمایی که بیصدا دادههای بد را رد میکند باگ خاموشی است که تا دیتابیس آلودگی میبرد.
قواعد نامگذاریای که نام فایل را به اصرار تبدیل میکند از متنباز کپیشدنی است:
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:test داخلی Node کافی است:
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 بدهید: اسنپشاتهای تازه بهجای ساختهشدن خاموش، شکست میخورند و رانش پذیرفته نمیشود.
۳. همه خطاها را در یک گذر جمع کنید
پیکربندیای که هنگام تست منفیها اغلب فراموش میشود: بهطور پیشفرض اعتبارسنج در نخستین خطا میایستد. داده در سه جا شکسته، شما یکی را میبینید، درست میکنید، دوباره اجرا میکنید — و همینطور. با 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)
پایتون هم واقعیتی خلاف شهود دارد: خطایی که validate() گزارش میکند «اولین» نیست، بلکه انتخاب best_match است. در اندازهگیری ما با سه نقض (نوع نادرست id، role بیرون فهرست، فیلد زائد extra) فقط additionalProperties گزارش شد — خطای نوع id پنهان ماند. در CI همیشه برای دید کامل از iter_errors استفاده کنید.
و دامی آسانتر: format بهطور پیشفرض اصرار نیست. پیشنویس ۲۰۲۰-۱۲ فرمت را حاشیهنویسی طبقهبندی کرده: jsonschema پایتون بدون format_checker آن را اعتبارسنجی نمیکند — در تست ما "not-an-email" آزادانه گذشت. Ajv هم نصب و ثبت ajv-formats میخواهد. اگر فرمتی مهم است (ایمیل، تاریخ، uuid)، باید فیکسچر منفیای باشد که ثابت کند پیکربندی اعتبارسنج شما واقعاً آن را بررسی میکند.
۴. یک بار کامپایل کنید — کامپایلر را یکبارمصرف تلقی نکنید
اندازهگیریهای محلی (Node v22.13.1، Ajv 8.17، همان اسکیمای سهلایه تو در تو، همان داده نقضگر):
| رویکرد | زمان برای ۱۰,۰۰۰ اعتبارسنجی |
|---|---|
هر بار new Ajv() و compile |
42233 ms |
نمونه در سطح ماژول + compile یک بار |
6.9 ms |
فاکتور ۶۰۰۰. اعتبارسنجی داخل هندلر ریکوئست که هر بار Ajv جدید میسازد، رایجترین حادثه کارایی است. یک باور نادرست رایج را هم ثبت کنیم: «ajv.validate(schema, data) هر بار از نو کامپایل میکند». اندازهگیری: ۱۰۰,۰۰۰ فراخوانی در 8.2 ms — قابل مقایسه با 11.2 ms تابع یکبارکامپایلشده، چون Ajv کش داخلی اسکیما دارد. بیماری واقعی از قلمافتادن کش نیست، باز استفاده نشدن نمونه است. در پایتون هم همینطور: Draft202012Validator بازاستفادهشده حدود ۵۰۰۰ بار سریعتر از فراخوانی هر باره validate() است (۲۰۰۰ تکرار: 0.3 ms در برابر 1566 ms)، چون دومی هر بار خود اسکیما را بررسی میکند.
۵. دروازهها را در CI جوش دهید: چهار در
از ارزان به گران مرتب شده: اگر جلوئیها بیفتند، لازم نیست پسرونیها اجرا شوند:
| دروازه | شکل فرمان | چه چیزی را میگیرد | هزینه |
|---|---|---|---|
| لینت اسکیما | node scripts/lint-schemas.mjs |
خطاهای نحوی، کلیدواژههای منسوخ | میلیثانیه |
| اعتبارسنجی فیکسچر | سوئیت بخش ۲ | ناهماهنگی فیکسچر/اسکیما، قیدهای تغییر یافته سهوی | میلیثانیه |
| دیف تغییرات شکننده | json-schema-diff-validator در برابر main |
حذف required، تنگتر شدن enum، سختتر شدن pattern | ثانیه |
| اعتبارسنجی پاسخ واقعی | تستهای smoke روی staging با اصرار .toMatchSchema() |
رانش نقطه پایانی واقعی | ثانیه |
حداقل پیکربندی GitHub Actions:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # سهگانه فیکسچر
- run: npx jest --ci --testPathPattern=schema # نگهبان رانش اسنپشات
دروازه تغییرات شکننده تأکیدی جداگانه میطلبد: ابزارهایی مثل json-schema-diff-validator «فیلد required حذفشده» و «مقدار از دسترفته enum» را همان در بازبینی سرخ میکنند — دقیقاً دو تغییری که API-diffهای معمولی با آسودگی «غیرشکننده» میخوانند. برداشتن یک فیلد پاسخ برای مصرفکننده سازگار بیآزار است و برای مصرفکنندهای با سوییچ جامع فروپاشی پروداکشن.
۶. شیء خطا را اصرار کنید، نه فقط بولین را
تست منفیای که فقط validate(data) === false را میآزماید کافی نیست. برای همان اسکیما مطمئن شوید به دلیل درست رد میکند: ممکن است قیدی بیگانه تصادفی گذشت کرده باشد. شیء خطای Ajv نامتقارنی معروفی دارد: وقتی required شکست میخورد، instancePath به شیء والد اشاره میکند و نام فیلد غایب در params.missingProperty پنهان است. در اندازهگیری ما با id غایب:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
اصرار نوشته میشود «خطایی با keyword=required و missingProperty=id وجود دارد»، نه errors.length > 0 مبهم. رمزگشایی کامل معنای فیلدهای خطا در خطاهای JSON Schema نافهمیدنی: محلیابی خطاهای Ajv و مرتبسازی پیامها.
۷. دامهای پرتکرار — جدول سریع
| دام | پیامد | درمان |
|---|---|---|
| آزمودن فقط ۲۰۰ مسیر خوشحال | رانش پاکتهای خطا کسی را آگاه نمیکند | برای پاسخهای خطا هم اسکیما و فیکسچر |
| اسکیماهای تولیدشده بدون بازبینی | required استنباطشده از یک نمونه داده واقعی را رد میکند؛ تیم اعتبارسنجی را خاموش میکند | بازبینی انسانی required/enum پس از تولید |
| یک اسکیمای سراسری برای همه نسخهها | تفاوتهای v1/v2 پاک میشود | اسکیما را همراه نقطههای پایانی نسخهبندی کنید |
| کل payload در لاگ هنگام شکست | PII و اسرار به لاگ CI نشت میکند | فقط مسیرهای JSON Pointer و قطعههای کوتاه بنویسید |
| enumهای منجمد | تغییر نام در محصول (canceled→cancelled) اعتبارسنجی را میشکند | تغییرهای enum از بازبینی سازگاری |
| نسخههای اعتبارسنج پیننشده | رفتارهای ناهمسان میان ماشینها | نسخه اعتبارسنج را در CI پین کنید |
| جایگزینی تستهای مجوز | 403 ممکن است با اسکیمای خطا بخواند و منطق نقش هنوز غلط باشد | اسکیما ساختار را میبیند؛ مجوز جداگانه تست میشود |
| فراموشی معنای format | آدرسهای زباله آزادانه میگذرند | نصب ajv-formats / دادن format_checker، افزودن منفیها |
۸. چکلیست پیادهسازی
- فایلهای اسکیما در کنترل نسخه،
node scripts/lint-schemas.mjsدر لینت؛ - برای هر اسکیما دستکم ۱ فیکسچر مثبت و بهازای هر قید ۱ منفی؛ نام فایل خودِ اصرار است؛
- شیءهای خطا اسنپشات شوند؛ در CI
--ciبدهید تا اسنپشات خودبهخود رانش نکند؛ - نمونههای اعتبارسنج در سطح ماژول بازاستفاده شوند — هرگز new داخل هندلرها؛
- دیف تغییرات شکننده به PR آویزان شود؛ تغییرهای required/enum با بازبینی اجباری؛
- نخستین PR عمداً ثابت میکند دروازه میتواند بیفتد: یک required بردارید، سرخی را ببینید، بازگردانید.
پرسش شدت اعتبارسنجی (کی additionalProperties را ببندیم) موضوع دیگری است — ببینید additionalProperties باز مانده: اعتبارسنجی اسکیما بیهوده است. این مقاله درباره نگه داشتن قواعد نوشتهشده پیوسته معتبر است. برای بنیادها از JSON Schema از مقدمات تا مهندسی آغاز کنید.
FAQ
رابطه اعتبارسنجی اسکیما و تست قرارداد به سبک Pact چیست؟
تقسیم کار است. اعتبارسنجی اسکیما پاسخ میدهد «آیا این سند از نظر ساختاری درست است» — اصراری یکسویه. تست قرارداد مصرفکنندهمحور (Pact) پاسخ میدهد «آیا فراهمکننده هنوز نیازهای مشخص مصرفکننده X را برآورده میکند» و بازبینی دوسویه میخواهد. تیمهای کوچک با دروازه اسکیما شروع کنند و با رشد شمار سرویسها Pact بگیرند.
چند تست منفی کافی است؟
یک منفی برای هر قید کف است: به تعداد قیدهای اسکیما (required، type، minimum، enum، pattern، additionalProperties…) فیکسچر منفی، بهعلاوه جفتهای مقدار مرزی رد/قبول. منفیهای کمتر از قیدها یعنی قیدی بینگهبان مانده.
کدام تغییر اسکیما «شکننده» است؟
از دید مصرفکننده داوری کنید: حذف فیلد پاسخ، حذف یا تنگتر کردن enum، سختتر کردن pattern، افزودن فیلد اجباری ریکوئست — همه شکنندهاند؛ افزودن فیلد اختیاری پاسخ معمولاً بیخطر است — به شرط آنکه دیکودر مصرفکننده کلیدهای ناشناس را تاب بیاورد. مقایسه را با json-schema-diff-validator در مرحله PR خودکار کنید و نواحی خاکستری را خودتان داوری کنید.
تست به API واقعی بزنیم یا mock؟
هر دو، اما لایهای: تستهای فیکسچر شبکه را لمس نمیکنند، در میلیثانیه میروند و در هر PR اجرا میشوند؛ اعتبارسنجی واقعی به staging میزند، ثانیهها طول میکشد، به پایپلاین smoke تعلق دارد و بیاعتبارنامهها با skipif میپرد. تیمهایی که در CI فقط API واقعی میرانند با لرزش شبکه و محدودیت نرخ فرسوده میشوند و سرانجام تستها را خاموش میکنند.