การทดสอบสัญญา JSON Schema: เชื่อมกฎการตรวจสอบเข้ากับ CI
สรุปสำคัญ: สคีมาไม่จบเมื่อเขียนเสร็จ — ที่ตรงนั้นคือจุดเริ่มเสื่อมสภาพ การพิมพ์ผิดหนึ่งจุดทำให้การตรวจสอบหย่อนลงอย่างเงียบ ๆ การรีแฟกเตอร์หนึ่งครั้งทำลาย fixture ทั้งหมด คู่มือนี้ถอดโครงการทดสอบสัญญาที่ทำได้จริง: fixture บวก ลบ และ snapshot, lint ตัวสคีมาเอง คอมไพล์ครั้งเดียวใช้ซ้ำ และสี่ประตู CI ที่เปลี่ยนสัญญาแตกเป็นบิลด์แดง

บทสรุป: สคีมาคือโค้ด และโค้ดต้องมีการทดสอบ fixture บวกพิสูจน์ว่าปล่อยสิ่งที่ควรผ่าน; fixture ลบพิสูจน์ว่ากั้นสิ่งที่ต้องกั้น; snapshot เฝ้าดูการเปลี่ยนที่ไม่ได้ตั้งใจ สุดท้าย ajv compile และ snapshot ที่ใส่ --ci เฝ้ายามยามค่ำในไปป์ไลน์ของคุณ
ในชั่วขณะที่กฎการตรวจสอบแตก ไม่มีอะไรโยนข้อผิดพลาดออกมา: การขยายการยอมรับไม่มีใครสังเกต การเข้มขึ้นของการปฏิเสธทั้งทีมต้องแบกรับ สคีมาไม่ใช่ไฟล์คอนฟิก — มันคือโค้ดที่ไม่มีใครทดสอบ
1. สคีมาแตก — ใครรู้ก่อน
ขอเริ่มด้วยกับดักที่เราเหยียบจริง ประกาศสคีมาตามฉบับร่าง 2020-12 แล้วคอมไพล์ด้วยแพ็กเกจเริ่มต้นของ Ajv: ข้อยกเว้นพุ่งทันที:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
จุดเข้าเริ่มต้นของ Ajv 8 รู้จักแค่ draft-07; สำหรับ 2020-12 ต้อง import จาก ajv/dist/2020 กับดักอีกอัน: ในโหมด strict เขียน format: "email" โดยไม่ติดตั้ง ajv-formats ก็ล้มการคอมไพล์เช่นกัน:
Error: unknown format "email" ignored in schema at path "#/properties/email"
เห็นข้อผิดพลาดเหล่านี้ในเครื่องตัวเองตอนคอมไพล์ครั้งแรกถือว่าดี ปัญหาคือหลายโปรเจกต์เลื่อนการคอมไพล์ไปจนถึงตอน request มาถึง และสคีมาที่แตกก็รอเหยื่อรายแรก ลอบกวนกว่านั้นคือสคีมาแตกครึ่ง: required พลาดเขียนเป็นสตริงแทนอาร์เรย์ จะโยน schema is invalid: data/required must be array ตอนคอมไพล์ — แต่ถ้าขั้นตอนนี้ไม่เคยวิ่งใน CI มันก็นอนฝังอยู่ใน production
ประตูแรกจึงไม่ได้ตรวจข้อมูล: มันตรวจตัวสคีมาเอง หนึ่งคำสั่ง:
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 จากหัวข้อที่ 1 จะโยน 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 เท่านั้น
รันมันเหมือน linter: การพิมพ์ผิด คีย์เวิร์ดที่เลิกใช้ และฟอร์แมตที่ยังไม่ลงทะเบียนจะโผล่ตั้งแต่ขั้น PR นั่นคือความเหมือนแรกระหว่างการทดสอบสคีมากับการทดสอบโค้ดทั่วไป: ก่อนอื่นยืนยันว่าคอมไพล์ผ่าน
2. สามคู่หู: บวก ลบ snapshot
โครงกระดูกของการทดสอบสคีมามีสามชิ้น
Fixture บวก: ตัวอย่างที่เก็บจากการตอบจริง ซึ่งต้องผ่าน เอาจาก staging ไม่ใช่จากความจำ — ตัวบวกที่เขียนมือครอบคลุมแค่ happy path ลืม null สตริงว่าง และขอบเขตที่มีแต่ฟิลด์บังคับ
Fixture ลบ: สำหรับทุกข้อจำกัดของสคีมา ให้อย่างน้อยหนึ่งอินพุตที่ละเมิด สำหรับ 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)); });
}
}
Snapshot: ซีเรียลไลซ์ validate.errors ลงไฟล์แล้วเทียบ ใครบางคนเผลอเปลี่ยนข้อจำกัด — snapshot แดงทันที ใน Jest/Vitest คือหนึ่งบรรทัด: expect(errors).toMatchSnapshot(); ใน CI ให้ --ci เสมอ: snapshot ใหม่จะล้มแทนที่จะถูกสร้างเงียบ ๆ และการเลื่อนไหลไม่ถูกยอมรับ
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 เป็นคำอธิบายประกอบ: jsonschema ของ Python ไม่ตรวจมันหากไม่ส่ง format_checker — ในการทดลองของเรา "not-an-email" ผ่านอย่างอิสระ Ajv ต้องติดตั้งและลงทะเบียน ajv-formats ถ้าฟอร์แมตสำคัญ (อีเมล วันที่ uuid) ต้องมี fixture ลบที่พิสูจน์ว่าการตั้งค่าตัวตรวจของคุณตรวจมันจริง
4. คอมไพล์ครั้งเดียว — อย่าถือว่าคอมไพลเลอร์เป็นของใช้แล้วทิ้ง
การวัดในเครื่อง (Node v22.13.1, Ajv 8.17, สคีมาซ้อนสามชั้นเดียวกัน ข้อมูลละเมิดชุดเดียวกัน):
| แนวทาง | เวลาสำหรับการตรวจ 10,000 ครั้ง |
|---|---|
ทุกครั้ง new Ajv() แล้ว compile |
42233 ms |
อินสแตนซ์ระดับโมดูล + compile ครั้งเดียว |
6.9 ms |
ตัวคูณ 6000 ตัวตรวจใน request handler ที่สร้าง Ajv ใหม่ทุกครั้งที่เรียกคืออุบัติเหตุประสิทธิภาพที่พบบ่อยที่สุด ขอบันทึกความเข้าใจผิดที่แพร่หลายด้วย: "ajv.validate(schema, data) คอมไพล์ใหม่ทุกครั้ง" วัดแล้ว: 100,000 ครั้งเรียกใช้ 8.2 ms — เทียบเท่า 11.2 ms ของฟังก์ชันที่คอมไพล์ครั้งเดียว เพราะ Ajv เก็บแคชสคีมาภายใน โรคจริงไม่ใช่แคชพลาด แต่คือไม่ใช้อินสแตนซ์ซ้ำ Python ก็เช่นกัน: Draft202012Validator ที่ใช้ซ้ำเร็วกว่าการเรียก validate() ทุกครั้งราว 5000 เท่า (2000 รอบ: 0.3 ms เทียบ 1566 ms) เพราะอันหลังทุกครั้งเรียกตรวจสอบสคีมาเองด้วย
5. เชื่อมประตูเข้ากับ CI: สี่บาน
เรียงจากถูกไปแพง: ถ้าบานหน้าล้ม บานหลังไม่ต้องรัน:
| ประตู | รูปคำสั่ง | จับอะไร | ต้นทุน |
|---|---|---|---|
| lint สคีมา | node scripts/lint-schemas.mjs |
ข้อผิดพลาดไวยากรณ์ คีย์เวิร์ดเลิกใช้ | มิลลิวินาที |
| ตรวจ fixture | ชุดทดสอบในหัวข้อ 2 | fixture กับสคีมาไม่ตรง ข้อจำกัดถูกแก้พลาด | มิลลิวินาที |
| diff การเปลี่ยนที่ทำลาย | json-schema-diff-validator เทียบกับ main |
required ถูกลบ enum ถูกหด pattern ถูกเข้ม | วินาที |
| ตรวจการตอบจริง | ทดสอบ smoke ยิง staging ด้วยการยืนยัน .toMatchSchema() |
การเลื่อนของ endpoint จริง | วินาที |
การตั้งค่าขั้นต่ำของ GitHub Actions:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # สามคู่หู fixture
- run: npx jest --ci --testPathPattern=schema # ยามกัน snapshot เลื่อนไหล
ประตูการเปลี่ยนที่ทำลายควรได้รับการเน้นแยก: เครื่องมืออย่าง json-schema-diff-validator ทำสีแดง "ฟิลด์ required ถูกลบ" และ "ค่า enum หายไป" ตั้งแต่ขั้นทบทวน — ตรงสองการเปลี่ยนที่ API-diff ทั่วไปเต็มใจตัดสินว่า "ไม่ทำลาย" การเอาฟิลด์การตอบออกไม่เป็นอันตรายกับผู้บริโภคที่อดทน และเป็นล้มของ production กับผู้บริโภคที่ใช้ switch ครบทุกทาง
6. ยืนยันบนออบเจกต์ข้อผิดพลาด ไม่ใช่แค่บูลีน
การทดสอบฝั่งลบที่ยืนยันแค่ 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 และดูแลข้อความ
7. กับดักที่พบบ่อย — ตารางด่วน
| กับดัก | ผลลัพธ์ | ยา |
|---|---|---|
| ทดสอบแค่ 200 happy path | การเลื่อนไหลของซองข้อผิดพลาดไม่มีใครสังเกต | สคีมาและ fixture สำหรับการตอบข้อผิดพลาดด้วย |
| สคีมาจากเครื่องมือสร้างไม่ถูกทบทวน | required อนุมานจากตัวอย่างเดียวปฏิเสธข้อมูลจริง; ทีมปิดการตรวจสอบ | ให้คนทบทวน required/enum หลังสร้าง |
| สคีมาเดียวทั้งระบบรับทุกเวอร์ชัน | ความต่าง v1/v2 ถูกลบเลือน | ทำเวอร์ชันสคีมาพร้อม endpoint |
| เท payload เต็มลง log ตอนล้ม | PII และความลับรั่วเข้า log CI | เขียนแค่เส้นทาง JSON Pointer และตัดสั้น |
| enum แข็งค้าง | เปลี่ยนชื่อในผลิตภัณฑ์ (canceled→cancelled) ทำการตรวจแตก | เปลี่ยน enum ผ่านการทบทวนความเข้ากันได้ |
| เวอร์ชันตัวตรวจไม่ถูกปักหมุด | พฤติกรรมต่างกันระหว่างเครื่อง | ปักหมุดเวอร์ชันตัวตรวจใน CI |
| ใช้แทนการทดสอบการอนุญาต | 403 อาจเข้ากับสคีมาข้อผิดพลาดแต่ตรรกะบทบาทยังผิด | สคีมาดูโครงสร้าง; การอนุญาตทดสอบแยก |
| ลืมความหมายของ format | ที่อยู่ขยะผ่านอย่างอิสระ | ติดตั้ง ajv-formats / ส่ง format_checker เพิ่มตัวลบ |
8. รายการตรวจการนำไปใช้
- ไฟล์สคีมาเข้าระบบควบคุมเวอร์ชัน,
node scripts/lint-schemas.mjsเข้า lint; - สคีมาแต่ละตัวมี fixture บวกอย่างน้อย 1 ตัวและ fixture ลบ 1 ตัวต่อข้อจำกัด; ชื่อไฟล์คือการยืนยัน;
- ออบเจกต์ข้อผิดพลาดเป็น snapshot; ใน CI ให้
--ciเพื่อไม่ให้ snapshot เลื่อนไหลเอง; - อินสแตนซ์ตัวตรวจใช้ซ้ำระดับโมดูล — ห้าม new ใน handler;
- diff การเปลี่ยนที่ทำลายแขวนบน PR; การแก้ required/enum บังคับทบทวน;
- PR แรกพิสูจน์โดยเจตนาว่าประตูล้มได้: เอา required หนึ่งออก เห็นแดง คืนกลับ
เรื่องความเข้มงวดของการตรวจ (เมื่อไรควรปิด additionalProperties) เป็นอีกหัวข้อ — ดูที่ additionalProperties ปล่อยเปิด: การตรวจสอบสคีมาไร้ค่า บทความนี้เรื่องการรักษากฎที่เขียนไว้ให้เชื่อถือได้อย่างต่อเนื่อง อยากเริ่มจากฐาน ไปที่ JSON Schema จากพื้นฐานถึงวิศวกรรม
FAQ
การตรวจสอบสคีมากับการทดสอบสัญญาแบบ Pact เกี่ยวข้องกันอย่างไร?
เป็นการแบ่งงาน การตรวจสอบสคีมาตอบว่า "เอกสารนี้ถูกโครงสร้างหรือไม่" — การยืนยันทางเดียว การทดสอบสัญญาที่ผู้บริโภคนำ (Pact) ตอบว่า "ผู้ให้บริการยังตอบความต้องการเฉพาะของผู้บริโภค X หรือไม่" และต้องการการตรวจสอบสองทาง ทีมเล็กเริ่มด้วยประตูสคีมา แล้วค่อยรับ Pact เมื่อจำนวนบริการโต
การทดสอบฝั่งลบกี่ตัวถึงพอ?
หนึ่งลบต่อหนึ่งข้อจำกัดคือเพดานล่าง: fixture ลบมากเท่าจำนวนข้อจำกัดของสคีมา (required, type, minimum, enum, pattern, additionalProperties…) บวกคู่ค่าขอบเขตปฏิเสธ/ยอมรับ ลบน้อยกว่าข้อจำกัดแปลว่ามีข้อจำกัดไร้ผู้เฝ้า
การเปลี่ยนสคีมาแบบไหนนับเป็น "ทำลาย"?
ตัดสินจากมุมผู้บริโภค: เอาฟิลด์การตอบออก เอา enum ออกหรือหดลง เข้ม pattern เพิ่มฟิลด์บังคับใน request — ทั้งหมดทำลาย; เพิ่มฟิลด์เลือกได้ในการตอบมักปลอดภัย — ภายใต้เงื่อนไขว่าตัวถอดรหัสของผู้บริโภคยอมรับคีย์ไม่รู้จัก ทำการเทียบอัตโนมัติด้วย json-schema-diff-validator ตอน PR และพื้นที่สีเทาให้คนตัดสิน
ทดสอบยิงไปที่ API จริงหรือ mock?
ทั้งสอง แบบเป็นชั้น: การทดสอบ fixture ไม่แตะเครือข่าย วิ่งในมิลลิวินาที รันทุก PR; การตรวจจริงยิง staging ใช้เวลาวินาที อยู่ในไปป์ไลน์ smoke และข้ามผ่าน skipif เมื่อไม่มีข้อมูลรับรอง ทีมที่ใน CI รันแต่ API จริงจะเหนื่อยกับเครือข่ายสั่นและขีดจำกัดอัตรา แล้วสุดท้ายปิดการทดสอบ