Testing JSON Schemas: Ajv + Jest, Vitest, and Snapshot Testing
Last updated:
Testing JSON Schemas means writing automated tests that verify your schema correctly validates valid inputs, rejects invalid ones, and produces useful error messages — not just that your Ajv setup works.
A well-tested schema suite covers 3 categories: positive tests (valid inputs that must pass), negative tests (invalid inputs that must fail), and error message tests (the errors array has the expected path and keyword). Aim for at least 10 test cases per schema.
This guide covers schema testing with Jest and Vitest, snapshot-testing error messages, the official JSON Schema Test Suite, and Ajv JSON Schema validator's test infrastructure.
Test file structure: describe blocks, valid/invalid groups, shared Ajv instance
Compile your schema once at the top of the test file — outside any describe or it block. Compiling inside a test body repeats an expensive operation on every run and can hide stale-instance bugs.
// user.schema.test.ts
import Ajv from 'ajv'
import addFormats from 'ajv-formats'
import { userSchema } from '../schemas/user'
// Shared Ajv instance — compile ONCE outside describe blocks
const ajv = new Ajv({ allErrors: true })
addFormats(ajv)
const validate = ajv.compile(userSchema)
describe('userSchema', () => {
describe('valid inputs', () => {
it('accepts a complete user object', () => {
expect(validate({ id: 1, email: 'a@b.com', name: 'Alice', role: 'user' })).toBe(true)
})
it('accepts minimum required fields only', () => {
expect(validate({ id: 1, email: 'a@b.com', name: 'Alice', role: 'guest' })).toBe(true)
})
})
describe('invalid inputs', () => {
it('rejects missing email', () => {
const result = validate({ id: 1, name: 'Alice', role: 'user' })
expect(result).toBe(false)
})
it('rejects wrong role value', () => {
const result = validate({ id: 1, email: 'a@b.com', name: 'Alice', role: 'superuser' })
expect(result).toBe(false)
})
})
})Group tests into at minimum two describe blocks: one for valid inputs and one for invalid inputs. Add a third block for error message assertions when your schema produces custom error text. A consistent structure makes it easy to see at a glance which constraints are covered and which are missing.
Keep one Ajv instance per test file or use a shared test helper. If you instantiate Ajv with different options in different tests (allErrors: true in some, false in others), the errors array will have different lengths and your error assertions will be unreliable.
Positive tests: edge cases (null, 0, empty string, empty array), minimum required fields
Positive tests verify that valid data passes — including edge cases that schema authors frequently get wrong. The most common false negatives (schema incorrectly rejects valid data) come from nullable fields, zero values, and empty collections.
describe('positive edge cases', () => {
it('accepts null for optional nullable field', () => {
// Schema: { "nickname": { "type": ["string", "null"] } }
expect(validate({ id: 1, email: 'a@b.com', name: 'Alice', role: 'user', nickname: null })).toBe(true)
})
it('accepts 0 for numeric field with minimum: 0', () => {
// Schema: { "score": { "type": "number", "minimum": 0 } }
expect(validateScore({ score: 0 })).toBe(true)
})
it('accepts empty string when minLength is not set', () => {
// Schema: { "description": { "type": "string" } }
expect(validatePost({ title: 'Hello', description: '' })).toBe(true)
})
it('accepts empty array when minItems is not set', () => {
// Schema: { "tags": { "type": "array", "items": { "type": "string" } } }
expect(validatePost({ title: 'Hello', description: 'World', tags: [] })).toBe(true)
})
it('accepts object with minimum required fields only', () => {
// Only required fields — no optional properties
expect(validate({ id: 1, email: 'a@b.com', name: 'Alice', role: 'user' })).toBe(true)
})
it('accepts maximum allowed string length', () => {
// Schema: { "name": { "type": "string", "maxLength": 100 } }
const name = 'A'.repeat(100)
expect(validate({ id: 1, email: 'a@b.com', name, role: 'user' })).toBe(true)
})
})Write at least one positive test for each optional field (present, absent, and null if nullable). A schema that only passes with fully-populated objects will trigger false rejections in production when users omit optional fields. Pay particular attention to fields with default values — test that the schema accepts the object both with and without the field.
For numeric boundaries, test the exact minimum and maximum values (not just values clearly inside the range). A schema with "minimum": 1 that accidentally reads as "minimum": 2 is caught only if you test the value 1 as a positive case.
Negative tests: wrong type, missing required, enum violation, format violation
Negative tests confirm that invalid data is rejected. Write one test per constraint category — do not combine multiple violations in one test case, or a failure in one constraint may mask another that is also broken.
describe('invalid inputs', () => {
// Wrong type
it('rejects string id when integer is required', () => {
expect(validate({ id: 'abc', email: 'a@b.com', name: 'Alice', role: 'user' })).toBe(false)
})
// Missing required property
it('rejects object missing required "name"', () => {
expect(validate({ id: 1, email: 'a@b.com', role: 'user' })).toBe(false)
})
// Enum violation
it('rejects role not in enum', () => {
expect(validate({ id: 1, email: 'a@b.com', name: 'Alice', role: 'superuser' })).toBe(false)
})
// Format violation
it('rejects malformed email address', () => {
expect(validate({ id: 1, email: 'not-an-email', name: 'Alice', role: 'user' })).toBe(false)
})
// Boundary — one below minimum
it('rejects id of 0 when minimum is 1', () => {
expect(validate({ id: 0, email: 'a@b.com', name: 'Alice', role: 'user' })).toBe(false)
})
// String too long — one character above maxLength
it('rejects name exceeding 100 characters', () => {
const name = 'A'.repeat(101)
expect(validate({ id: 1, email: 'a@b.com', name, role: 'user' })).toBe(false)
})
// Additional property when additionalProperties: false
it('rejects unexpected property', () => {
expect(validate({ id: 1, email: 'a@b.com', name: 'Alice', role: 'user', extra: true })).toBe(false)
})
})Test boundary values (off-by-one) for every minimum, maximum, minLength, maxLength, minItems, and maxItems constraint. A schema with "maxLength": 100 should reject 101 characters and accept exactly 100.
Format violations are easy to miss. If you rely on ajv-formats for email, date, or UUID validation, confirm that obviously invalid strings are rejected — "not-an-email", "2025-13-01" (invalid month), "not-a-uuid". Forgetting to call addFormats(ajv) causes format keywords to be silently ignored; negative format tests catch this configuration mistake immediately.
Assert on errors array: instancePath, keyword, schemaPath, custom error messages
Testing only the boolean result of validate(data) is not enough — a false-returning validator could be failing on the wrong constraint. Assert on the errors array to confirm the right rule triggered, the right field is identified, and any custom error message appears as expected.
describe('error assertions', () => {
it('reports instancePath "/email" for missing email', () => {
validate({ id: 1, name: 'Alice', role: 'user' })
// For required errors, instancePath is '' (root) and params.missingProperty is the field
expect(validate.errors).toContainEqual(
expect.objectContaining({
keyword: 'required',
params: { missingProperty: 'email' },
})
)
})
it('reports instancePath "/email" for invalid email format', () => {
validate({ id: 1, email: 'bad', name: 'Alice', role: 'user' })
expect(validate.errors).toContainEqual(
expect.objectContaining({
instancePath: '/email',
keyword: 'format',
message: 'must match format "email"',
})
)
})
it('reports instancePath "/id" for wrong type', () => {
validate({ id: 'abc', email: 'a@b.com', name: 'Alice', role: 'user' })
expect(validate.errors).toContainEqual(
expect.objectContaining({
instancePath: '/id',
keyword: 'type',
})
)
})
it('reports correct schemaPath for enum violation', () => {
validate({ id: 1, email: 'a@b.com', name: 'Alice', role: 'bad' })
expect(validate.errors).toContainEqual(
expect.objectContaining({
instancePath: '/role',
keyword: 'enum',
schemaPath: '#/properties/role/enum',
})
)
})
})Use expect.objectContaining({}) rather than exact equality — error objects have 5+ fields and matching all of them makes tests brittle. Only assert on the fields meaningful to the test: instancePath, keyword, and message are usually sufficient.
For schemas with JSON Schema custom error messages via errorMessage (ajv-errors plugin), assert that the message field contains your custom text rather than the default Ajv message. This prevents custom error messages from silently regressing to defaults when the schema changes.
Snapshot testing schemas: jest.toMatchSnapshot() on errors, updating snapshots safely
Snapshot tests capture the full errors array for a given invalid input and compare it to a saved baseline on every subsequent run. They are the lowest-effort way to catch unintended changes to error messages, paths, or params when you update a schema.
describe('snapshot tests', () => {
it('matches error snapshot for missing required fields', () => {
validate({}) // Empty object — all required fields missing
expect(validate.errors).toMatchSnapshot()
})
it('matches error snapshot for all-invalid input', () => {
validate({ id: 'bad', email: 'bad', name: 42, role: 'unknown' })
expect(validate.errors).toMatchSnapshot()
})
})
// Generated __snapshots__/user.schema.test.ts.snap:
// exports[`userSchema snapshot tests matches error snapshot for missing required fields 1`] = `
// Array [
// Object {
// "instancePath": "",
// "keyword": "required",
// "message": "must have required property 'id'",
// "params": Object { "missingProperty": "id" },
// "schemaPath": "#/required",
// },
// ... more errors
// ]
// `On the first run, Jest creates a __snapshots__ directory alongside your test file with a .snap file. Commit this file — it is the baseline. On CI, any snapshot mismatch fails the build, which is the desired behavior.
When you intentionally update a schema (adding a new required field, changing a custom message), update the snapshots by running jest --updateSnapshot (or jest -u). Review the diff in git before committing — the snapshot diff shows exactly what changed in the error output. Never run --updateSnapshot in CI; snapshot updates should be a deliberate local action.
Limit snapshot tests to 2–3 representative invalid inputs per schema. Snapshotting every permutation produces enormous snapshot files that developers stop reading. Use explicit error assertions (Section 4) for specific constraints and reserve snapshots for regression-catching on complex schemas.
Using the official JSON Schema Test Suite: running with Ajv, identifying gaps
The official JSON Schema Test Suite verifies that your validator is spec-compliant — not just that it works for your schemas, but that it correctly handles every keyword combination defined in the draft. Running it is the best way to catch Ajv configuration mistakes or version mismatches.
# Clone the test suite alongside your project
git clone https://github.com/json-schema-org/JSON-Schema-Test-Suite.git test-suite
# Install Ajv for draft 2020-12
npm install ajv ajv-formats// json-schema-test-suite.test.ts
import fs from 'fs'
import path from 'path'
import Ajv2020 from 'ajv/dist/2020'
import addFormats from 'ajv-formats'
const ajv = new Ajv2020({ allErrors: true, strict: false })
addFormats(ajv)
// Point at the draft you want to verify
const suitePath = path.join(__dirname, '../test-suite/tests/draft2020-12')
// Load every .json file in the suite directory
const suiteFiles = fs.readdirSync(suitePath).filter(f => f.endsWith('.json'))
for (const file of suiteFiles) {
const groups = JSON.parse(fs.readFileSync(path.join(suitePath, file), 'utf8'))
describe(`JSON Schema Test Suite: ${file}`, () => {
for (const group of groups) {
describe(group.description, () => {
const validate = ajv.compile(group.schema)
for (const test of group.tests) {
it(test.description, () => {
const result = validate(test.data)
expect(result).toBe(test.valid)
})
}
})
}
})
}Run this test and look for failures in the Jest output. Common gaps: format validation requires ajv-formats; unevaluatedProperties requires Draft 2019-09+ and the 2020 Ajv import; some $ref behaviors differ between drafts. Each failure points to a real spec deviation in your Ajv setup.
You do not need to pass every test in the suite if you only use a subset of keywords. Skip test files for keywords you do not use — suiteFiles.filter(f => !['optional', 'format'].includes(f.replace('.json',''))) — to keep the suite focused on your actual usage. The goal is identifying gaps in your validator configuration, not achieving 100% suite compliance.
CI integration: pre-commit schema validation, Jest coverage for schemas, GitHub Actions
Schema tests should run in CI on every pull request. The minimal GitHub Actions setup adds one step after npm install:
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx jest --ci --coverage --testPathPattern="schema"
# --ci fails on new snapshots instead of creating them
# --coverage reports which schema files are exercisedThe --ci flag is critical in GitHub Actions — without it, Jest silently creates new snapshot files when it encounters a snapshot call with no existing baseline. With --ci, new snapshots cause a test failure, forcing the developer to explicitly run jest -u locally and commit the snapshot.
For pre-commit validation, add a lint-stagedentry that runs Ajv's CLI on changed schema files:
// package.json
{
"lint-staged": {
"schemas/**/*.json": [
"npx ajv validate -s $0 --strict --no-errors"
]
},
"scripts": {
"test:schemas": "jest --testPathPattern='schema' --ci",
"test:coverage": "jest --coverage --collectCoverageFrom='schemas/**/*.ts'"
}
}Track schema test coverage separately from application code coverage. Add a collectCoverageFrom glob that targets your schema files. A coverage gate of 90%+ on schema files ensures that new schemas committed without tests are flagged in the PR.
Definitions
- Schema test suite
- A collection of test cases — each pairing an input value with an expected validation result — that together verify a JSON Schema behaves correctly across all its constraints.
- Positive test
- A test case where the input is valid and
validate(data)must returntrue. Positive tests verify the schema does not over-restrict valid inputs. - Negative test
- A test case where the input violates a schema constraint and
validate(data)must returnfalse. Negative tests verify the schema enforces each constraint. - instancePath
- A JSON Pointer string (e.g.
/address/city) on each Ajv error object that identifies the location in the validated data where the constraint failed. An empty string means the root object. - schemaPath
- A JSON Pointer into the schema itself (e.g.
#/properties/email/format) that identifies which schema keyword triggered the error. Useful for debugging which part of a complex schema is failing. - Snapshot test
- A test technique where the output of an expression (such as
validate.errors) is serialized to a file on the first run and compared against that file on subsequent runs to detect unintended changes. - JSON Schema Test Suite
- The official community-maintained test suite at github.com/json-schema-org/JSON-Schema-Test-Suite, containing 400+ test cases organized by draft and keyword, used to verify that a validator is spec-compliant.
Frequently asked questions
How do I test a JSON Schema with Jest?
Install Ajv and Jest, then create a test file that imports your schema and a shared Ajv instance. Compile the schema once outside the test blocks: const validate = ajv.compile(mySchema). In each test, call validate(data) and assert the boolean result with expect(validate(data)).toBe(true) for valid inputs and expect(validate(data)).toBe(false) for invalid ones. For invalid cases, also assert on validate.errors to confirm the right constraint failed. See the Ajv JSON Schema validator guide for Ajv setup details.
What should I test in a JSON Schema?
Test three categories: positive tests (valid inputs including edge cases like null, 0, empty string, and minimum required fields only), negative tests (invalid inputs covering each constraint — wrong type, missing required, enum violation, format violation, boundary values), and error message tests (assert that validate.errors contains the expected instancePath, keyword, and message). Aim for at least 10 test cases per schema. See the validation keywords reference for a complete list of testable constraints.
How do I assert on specific Ajv validation errors in tests?
After validate(data) returns false, access validate.errors — an array of error objects. Each error has instancePath (JSON Pointer to the failing field), keyword (e.g. required, type, minLength), schemaPath, message, and params. In Jest, use expect(validate.errors).toContainEqual(expect.objectContaining({ instancePath: "/email", keyword: "format" })). Use expect.objectContaining to match a subset of the error object without specifying every field — this keeps tests readable and resilient to Ajv version changes.
How do I snapshot test JSON Schema error messages?
Call validate(data) with an invalid input, then call expect(validate.errors).toMatchSnapshot(). Jest saves the errors array to a __snapshots__ file on the first run. On subsequent runs, Jest compares the current errors array against the saved snapshot and fails if anything changed. Update snapshots intentionally with jest --updateSnapshot after a deliberate schema change. Commit the snapshot files to version control so CI can compare against them. Never run --updateSnapshot in CI.
What is the official JSON Schema Test Suite?
The JSON Schema Test Suite is a community-maintained collection of 400+ test cases organized by draft (draft4, draft7, 2019-09, 2020-12) and keyword. Each test file is a JSON array of groups; each group has a schema and an array of {description, data, valid} cases. Running your validator against the suite verifies spec compliance — failures mean your validator diverges from the spec. It is the most reliable way to catch Ajv configuration mistakes or import errors (e.g., using the Draft-07 import for a 2020-12 schema).
How do I test JSON Schema with Vitest instead of Jest?
Vitest uses the same describe/it/expect API as Jest, so your schema test files work with minimal changes. Replace jest.fn() with vi.fn() if needed, and replace jest.toMatchSnapshot() with expect(...).toMatchSnapshot() — Vitest supports snapshots natively. Configure Vitest in vitest.config.ts with the same include glob as your Jest config. One advantage: Vitest runs in the same ESM environment as your source, so no Babel transform is needed for ESM Ajv imports. Run with: npx vitest run.
How do I validate schemas automatically in CI?
Add npx jest --ci --testPathPattern="schema" as a step in your GitHub Actions workflow after npm ci. The --ci flag fails on new snapshots instead of creating them, preventing snapshot drift. For pre-commit checks, use lint-staged with npx ajv validate -s schema.json -d sample.json on changed schema files. Add a coverage threshold in jest.config.js (coverageThreshold: { global: { lines: 90 } }) and run jest --coverage in CI to enforce that new schemas are covered by tests. See validate JSON Schema in JavaScript for more automation patterns.
Should I test that my schema rejects specific invalid inputs?
Yes — negative tests are as important as positive ones. A schema that accidentally accepts invalid data is a silent bug that can corrupt databases or cause downstream failures. For each constraint in your schema (required, type, minimum, maxLength, enum, format, pattern), write at least one negative test that triggers that specific constraint. Test boundary values: if minimum is 0, test -1 (should fail) and 0 (should pass). These boundary tests frequently catch off-by-one errors in schema definitions. See the JSON Schema guide for constraint semantics.
Further reading and primary sources
- Ajv Documentation — Getting Started — Official Ajv docs covering installation, compilation, and error handling.
- JSON Schema Test Suite on GitHub — Official spec-compliance test suite with 400+ test cases across all drafts.
- Jest Snapshot Testing — Official Jest docs on snapshot testing: creating, updating, and managing snapshots.
- Vitest — Snapshot Testing — Vitest snapshot API — compatible with Jest snapshots, works with ESM natively.
Validate your JSON Schema now
Paste your JSON Schema and test data into Jsonic's validator — powered by Ajv — to see exactly which constraints fail and which fields are flagged.
Open JSON Schema Validator