JSON Toolkit

การทดสอบสัญญา JSON Schema: เชื่อมกฎการตรวจสอบเข้ากับ CI

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

ระดับกลางอ่านประมาณ 8 นาทีเผยแพร่ 2026-11-05อัปเดต 2026-11-05
แผนภาพการทดสอบสัญญา JSON Schema และประตูตรวจสอบ 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. รายการตรวจการนำไปใช้

  1. ไฟล์สคีมาเข้าระบบควบคุมเวอร์ชัน, node scripts/lint-schemas.mjs เข้า lint;
  2. สคีมาแต่ละตัวมี fixture บวกอย่างน้อย 1 ตัวและ fixture ลบ 1 ตัวต่อข้อจำกัด; ชื่อไฟล์คือการยืนยัน;
  3. ออบเจกต์ข้อผิดพลาดเป็น snapshot; ใน CI ให้ --ci เพื่อไม่ให้ snapshot เลื่อนไหลเอง;
  4. อินสแตนซ์ตัวตรวจใช้ซ้ำระดับโมดูล — ห้าม new ใน handler;
  5. diff การเปลี่ยนที่ทำลายแขวนบน PR; การแก้ required/enum บังคับทบทวน;
  6. 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 จริงจะเหนื่อยกับเครือข่ายสั่นและขีดจำกัดอัตรา แล้วสุดท้ายปิดการทดสอบ