Kiểm thử hợp đồng JSON Schema: hàn quy tắc xác thực vào CI
Kết luận: Schema không kết thúc khi viết xong — đó là nơi thoái hóa bắt đầu. Một lỗi chính tả âm thầm nới lỏng xác thực, một lần refactor làm hỏng mọi fixture. Hướng dẫn này trình bày bộ khung kiểm thử hợp đồng khả thi: fixture dương, âm và snapshot, lint chính schema, biên dịch một lần tái sử dụng, và bốn cổng CI biến vỡ hợp đồng thành build đỏ.

Kết luận: schema là mã, và mã cần kiểm thử. Fixture dương chứng minh nó cho qua đúng thứ cần; fixture âm chứng minh nó chặn đúng thứ phải; snapshot canh chừng thay đổi ngoài ý muốn. Cuối cùng ajv compile và snapshot với --ci gác đêm trong pipeline của bạn.
Vào khoảnh khắc quy tắc xác thực vỡ, chẳng có gì ném lỗi: nới rộng sự chấp nhận không ai để ý, siết chặt sự từ chối cả đội gánh chịu. Schema không phải tệp cấu hình — đó là mã mà chẳng ai kiểm thử.
1. Schema vỡ — ai biết trước tiên
Hãy bắt đầu với cái bẫy ta thật sự dẫm phải. Khai báo schema theo bản thảo 2020-12 và biên dịch bằng gói mặc định của Ajv: ngay lập tức một ngoại lệ vọt ra:
Error: no schema with key or ref "https://json-schema.org/draft/2020-12/schema"
Điểm vào mặc định của Ajv 8 chỉ nhận draft-07; với 2020-12 phải import từ ajv/dist/2020. Cái bẫy nữa: ở chế độ strict, viết format: "email" mà chưa cài ajv-formats cũng hạ luôn bước biên dịch:
Error: unknown format "email" ignored in schema at path "#/properties/email"
Thấy những lỗi này cục bộ ở lần biên dịch đầu là tốt. Vấn đề: nhiều dự án dời biên dịch đến khi request tới, và schema vỡ chờ nạn nhân đầu tiên. Xảo quyệt hơn nữa là schema nửa vỡ: required lỡ tay ghi thành chuỗi thay vì mảng sẽ ném schema is invalid: data/required must be array lúc biên dịch — nhưng nếu bước ấy chưa từng chạy trong CI, nó nằm ngủ trong production.
Vậy cổng đầu không xác thực dữ liệu: nó xác thực chính schema. Một lệnh:
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}`);
}
Tại sao không dùng npx ajv compile: gói ajv v8 không kèm CLI, npx ajv báo could not determine executable to run; CLI nằm ở gói riêng ajv-cli (đo được 5.0.0). Nhưng ajv-cli 5 chỉ hỗ trợ draft-07 và draft-2019-09 — trỏ vào schema 2020-12 ở mục 1 thì ném đúng lỗi no schema with key or ref "https://json-schema.org/draft/2020-12/schema". Vì vậy dự án 2020-12 lint bằng chính Ajv2020 như trong bài; npx ajv-cli compile -s '**/*.schema.json' --strict chỉ hợp với dự án draft-07.
Chạy nó như linter: lỗi gõ, từ khóa đã lỗi thời và format chưa đăng ký lộ diện ngay ở PR. Đó là điểm giống đầu tiên giữa kiểm thử schema và kiểm thử mã thường: trước hết xác nhận nó biên dịch được.
2. Bộ ba: dương, âm, snapshot
Bộ xương của kiểm thử schema gồm ba mảnh.
Fixture dương: mẫu lấy từ phản hồi thật, phải được qua. Hãy lấy từ staging chứ đừng viết từ trí nhớ — fixture dương viết tay chỉ phủ happy path, quên null, chuỗi rỗng và biên chỉ-gồm-trường-bắt-buộc.
Fixture âm: với mỗi ràng buộc của schema, ít nhất một đầu vào vi phạm. Với minimum: 0 cặp -1 (từ chối) và 0 (qua); với enum một giá trị ngoài danh sách; với additionalProperties: false một trường thừa. Fixture âm quan trọng hơn dương: schema lặng lẽ cho qua dữ liệu xấu là bug lặng, ô nhiễm tới tận cơ sở dữ liệu.
Quy ước đặt tên biến tên tệp thành khẳng định, chép được từ mã nguồn mở:
schemas/order.schema.json
fixtures/order/valid-minimal.json # phải qua
fixtures/order/valid-full.json # phải qua
fixtures/order/invalid-type.json # phải bị từ chối
fixtures/order/invalid-extra-field.json # phải bị từ chối
node:test tích hợp trong Node là đủ:
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} nên qua`, () => { assert.ok(validate(data), JSON.stringify(validate.errors)); });
} else {
test(`${f} nên bị từ chối`, () => { assert.ok(!validate(data)); });
}
}
Snapshot: serialize validate.errors ra tệp rồi so sánh. Ai đó vô tình đổi ràng buộc — snapshot lập tức đỏ. Trong Jest/Vitest là một dòng: expect(errors).toMatchSnapshot(); trong CI luôn truyền --ci: snapshot mới khi ấy thất bại thay vì được tạo lặng lẽ, và trôi không bị tha thứ.
3. Gom tất cả lỗi trong một lượt
Cấu hình hay bị bỏ qua khi kiểm thử fixture âm: mặc định bộ xác thực dừng ở lỗi đầu tiên. Dữ liệu vỡ ba chỗ, bạn thấy một, sửa, chạy lại — và cứ thế. Với allErrors: true (Ajv) hoặc iter_errors (Python), thấy tất cả một lần:
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 còn cất một sự thật ngược trực giác: lỗi nào validate() báo không phải "cái đầu" mà do best_match chọn. Trong phép đo của ta, với ba vi phạm (kiểu sai của id, role ngoài danh sách, trường thừa extra), chỉ additionalProperties được báo — lỗi kiểu của id còn khuất. Trong CI luôn dùng iter_errors cho toàn cảnh.
Và một bẫy còn dễ hơn: format không phải khẳng định theo mặc định. Bản thảo 2020-12 xếp format là chú thích: jsonschema của Python không xác thực nó nếu không truyền format_checker — trong thử nghiệm của ta "not-an-email" qua thoải mái. Ajv đòi cài và đăng ký ajv-formats. Nếu format quan trọng (email, ngày, uuid), phải có fixture âm chứng minh cấu hình bộ xác thực của bạn thật sự kiểm tra nó.
4. Biên dịch một lần — đừng coi bộ biên dịch là vật tiêu hao
Phép đo cục bộ (Node v22.13.1, Ajv 8.17, cùng schema lồng ba tầng, cùng dữ liệu vi phạm):
| Cách làm | Thời gian cho 10.000 lần xác thực |
|---|---|
Mỗi lần new Ajv() rồi compile |
42233 ms |
Thể hiện cấp module + compile một lần |
6.9 ms |
Hệ số 6000. Bộ xác thực nằm trong request handler tạo Ajv mới mỗi lời gọi là tai nạn hiệu năng phổ biến nhất. Ghi nhận cả một quan niệm sai lan rộng: "ajv.validate(schema, data) biên dịch lại mỗi lần". Đo được: 100.000 lời gọi hết 8.2 ms — sánh với 11.2 ms của hàm biên dịch một lần, vì Ajv giữ bộ nhớ đệm schema bên trong. Bệnh thật không phải cache miss, mà là không tái sử dụng thể hiện. Python cũng vậy: Draft202012Validator tái sử dụng nhanh hơn khoảng 5000 lần so với gọi validate() mỗi lần (2000 vòng lặp: 0.3 ms so với 1566 ms), vì cái sau mỗi lần gọi lại soát chính schema.
5. Hàn cổng vào CI: bốn cửa
Sắp từ rẻ đến đắt: nếu cổng trước rơi, cổng sau khỏi chạy:
| Cổng | Dạng lệnh | Bắt thứ gì | Chi phí |
|---|---|---|---|
| Lint schema | node scripts/lint-schemas.mjs |
Lỗi cú pháp, từ khóa lỗi thời | Mili giây |
| Xác thực fixture | Bộ kiểm thử ở mục 2 | Lệch pha fixture/schema, ràng buộc bị đổi nhầm | Mili giây |
| Diff thay đổi phá vỡ | json-schema-diff-validator đối chiếu main |
Xóa required, thu hẹp enum, siết pattern | Giây |
| Xác thực phản hồi thật | Smoke test vào staging với khẳng định .toMatchSchema() |
Trôi của endpoint thật | Giây |
Cấu hình tối thiểu GitHub Actions:
- run: npm ci
- run: npm run lint:schemas # Ajv2020 lint
- run: npm run test:schema # bộ ba fixture
- run: npx jest --ci --testPathPattern=schema # lính canh trôi snapshot
Cổng thay đổi phá vỡ đáng nhấn mạnh riêng: công cụ như json-schema-diff-validator tô đỏ "trường required bị xóa" và "giá trị enum bị mất" ngay từ khâu rà soát — đúng hai kiểu thay đổi mà API-diff thường hay bụng bảo "không phá vỡ". Bỏ một trường phản hồi vô hại với người tiêu dùng khoan dung và chí mạng với người tiêu dùng dùng switch đầy đủ.
6. Khẳng định trên đối tượng lỗi, đừng chỉ boolean
Kiểm thử âm chỉ khẳng định validate(data) === false là chưa đủ. Với cùng schema, hãy chắc nó từ chối vì đúng lý do: một ràng buộc lạ có thể lọt qua ngẫu nhiên. Đối tượng lỗi của Ajv có sự bất đối xứng nổi tiếng: khi required hỏng, instancePath chỉ vào đối tượng cha, và tên trường thiếu nấp trong params.missingProperty. Đo được khi thiếu id:
{ "instancePath": "", "schemaPath": "#/required",
"keyword": "required", "params": { "missingProperty": "id" } }
Khẳng định viết là "tồn tại lỗi với keyword=required và missingProperty=id", đừng mơ hồ errors.length > 0. Giải mã trọn vẹn ngữ nghĩa trường lỗi ở Lỗi JSON Schema khó đọc: định vị lỗi Ajv và chăm thông điệp.
7. Bẫy thường gặp — bảng nhanh
| Bẫy | Hậu quả | Thuốc |
|---|---|---|
| Chỉ thử 200 happy path | Trôi phong bì lỗi chẳng ai để ý | Schema và fixture cả cho phản hồi lỗi |
| Schema sinh tự động không soát | required suy từ một mẫu từ chối dữ liệu thật; đội tắt xác thực | Con người rà required/enum sau khi sinh |
| Một schema toàn cục cho mọi phiên bản | Khác biệt v1/v2 bị xóa nhòa | Phiên bản hóa schema cùng endpoint |
| Cả payload vào log khi hỏng | PII và bí mật rỉ vào log CI | Chỉ ghi đường JSON Pointer và mẩu cắt ngắn |
| Enum đóng băng | Đổi tên trong sản phẩm (canceled→cancelled) vỡ xác thực | Đổi enum qua rà soát tương thích |
| Phiên bộ xác thực chưa ghim | Hành xử lệch giữa các máy | Ghim phiên bản bộ xác thực trong CI |
| Thay kiểm thử ủy quyền | 403 khớp schema lỗi mà logic vai trò vẫn sai | Schema lo cấu trúc; ủy quyền thử riêng |
| Quên ngữ nghĩa format | Địa chỉ rác qua tự do | Cài ajv-formats / truyền format_checker, thêm âm |
8. Danh sách triển khai
- Tệp schema vào quản trị phiên bản,
node scripts/lint-schemas.mjsvào lint; - Mỗi schema ít nhất 1 fixture dương và mỗi ràng buộc 1 âm; tên tệp là khẳng định;
- Đối tượng lỗi thành snapshot; trong CI truyền
--ciđể snapshot không tự trôi; - Thể hiện bộ xác thực tái sử dụng cấp module — đừng new trong handler;
- Diff thay đổi phá vỡ treo lên PR; đổi required/enum buộc rà soát;
- PR đầu cố ý chứng minh cổng biết ngã: bỏ một required, thấy đỏ, hoàn lại.
Câu hỏi nghiêm ngặt của xác thực (khi nào đóng additionalProperties) là chuyện khác — xem additionalProperties để ngỏ: xác thực schema vô nghĩa. Bài này nói về giữ quy tắc đã viết luôn đáng tin. Muốn nền tảng, bắt đầu từ JSON Schema từ cơ bản đến kỹ nghệ.
FAQ
Xác thực schema và kiểm thử hợp đồng kiểu Pact liên hệ thế nào?
Đó là phân công việc. Xác thực schema trả lời "tài liệu này đúng cấu trúc không" — khẳng định một chiều. Kiểm thử hợp đồng dẫn dắt bởi người tiêu dùng (Pact) trả lời "nhà cung còn đáp nhu cầu cụ thể của người tiêu X không" và đòi soát hai chiều. Đội nhỏ khởi đầu với cổng schema, và nhận Pact khi số dịch vụ lớn lên.
Bao nhiêu kiểm thử âm là đủ?
Một âm mỗi ràng buộc là sàn: nhiều fixture âm bằng số ràng buộc schema có (required, type, minimum, enum, pattern, additionalProperties…), thêm cặp giá trị biên bị từ/nhận. Âm ít hơn ràng buộc nghĩa là một ràng buộc còn không ai trông.
Thay đổi schema nào tính là "phá vỡ"?
Xét từ góc nhìn người tiêu dùng: bỏ trường phản hồi, bỏ hay thu hẹp enum, siết pattern, thêm trường bắt buộc của request — hết sức phá vỡ; thêm trường tùy chọn của phản hồi thường an toàn — với điều kiện bộ giải mã của người tiêu dùng dung nạp khóa lạ. Tự động so sánh bằng json-schema-diff-validator lúc PR, và vùng xám hãy tự phân xử.
Kiểm thử vào API thật hay mock?
Cả hai, theo tầng: kiểm thử fixture không đụng mạng, chạy trong mili giây, chạy mỗi PR; xác thực thật đập vào staging, tốn giây, thuộc pipeline smoke, và bỏ qua qua skipif khi thiếu thông tin đăng nhập. Đội nào trong CI chỉ chạy API thật sẽ mòn mỏi vì mạng rung và giới hạn tần suất, và cuối cùng tắt kiểm thử.