API Testing JSON Schema Validation Guide: From Contract to CI
This API testing JSON Schema validation guide shows how to catch response drift, design durable schemas, and ship actionable contract checks in CI.
API Testing JSON Schema Validation Guide: From Contract to CI
JSON Schema validation belongs in API tests when a team needs to detect structural response drift before consumers discover it. A useful schema test proves that a response has the required fields, value types, formats, allowed variants, and nesting rules promised by the API contract. It does not replace status-code assertions, business-rule checks, authorization tests, or consumer-driven contracts. It gives those checks a precise structural boundary.
This API testing JSON Schema validation guide builds that boundary as an executable workflow. The examples use TypeScript, Vitest, Supertest, and Ajv, but the design applies to any runner and HTTP client. You will learn how to choose a JSON Schema dialect, prevent accidental permissiveness, validate success and error bodies, report failures that an engineer can act on, and decide which compatibility changes should stop a pull request.
The central idea is simple: treat schemas as reviewed production code. Keep them close to the behavior they describe, test the schemas themselves with known-good and known-bad fixtures, and make failures show both the violated contract path and the actual response. That turns an opaque must have required property message into a fast diagnosis.
Define What the Schema Test Owns
A response can be wrong even when it validates. An endpoint may return another customer's validly shaped record, calculate a total incorrectly, or send a 200 when the operation should be forbidden. Conversely, a response can be operationally correct while violating a documented shape because a field disappeared or changed from a number to a string. Separate these failure classes so the suite tells you what broke.
| Test layer | Question it answers | Typical assertion | Schema's role |
|---|---|---|---|
| Transport | Did HTTP behave as expected? | status, content type, cache header | None |
| Structure | Can consumers safely parse the body? | required fields, types, unions | Primary |
| Semantics | Are the values correct for this scenario? | total equals line-item sum | Supporting only |
| Security | Is access constrained correctly? | tenant isolation, redaction | Supporting only |
| Consumer compatibility | Can a known consumer still interact? | provider verification | Complements schema |
For an endpoint such as GET /orders/:id, a strong test makes at least four independent claims: the status is 200, the content type is JSON, the body conforms to the order schema, and the returned ID matches the requested order. If the schema assertion absorbs all four, a failure gives little guidance. Keep the assertions distinct and order them from transport to structure to semantics.
The schema should describe the public representation, not a database row or ORM type. Database fields such as internal flags, soft-delete timestamps, and encrypted values are implementation details. Generating a public schema directly from persistence types can normalize accidental data exposure because the test begins expecting whatever the database happens to contain.
Pick a Dialect and Make Validation Strict on Purpose
JSON Schema is a family of drafts. Keywords and their interpretation can differ by dialect, so record the dialect in the root schema and configure the validator consistently. Ajv supports multiple drafts through different imports and options; consult its official documentation before changing dialects. Do not silently copy a schema using newer keywords into a validator configured for an older draft.
A compact order response schema can begin like this:
export const orderSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://api.example.test/schemas/order.json',
type: 'object',
required: ['id', 'status', 'currency', 'total', 'items'],
additionalProperties: false,
properties: {
id: { type: 'string', minLength: 1 },
status: { enum: ['pending', 'paid', 'cancelled'] },
currency: { type: 'string', pattern: '^[A-Z]{3}$' },
total: { type: 'number', minimum: 0 },
items: {
type: 'array',
minItems: 1,
items: { $ref: '#/$defs/item' },
},
},
$defs: {
item: {
type: 'object',
required: ['sku', 'quantity', 'unitPrice'],
additionalProperties: false,
properties: {
sku: { type: 'string', minLength: 1 },
quantity: { type: 'integer', minimum: 1 },
unitPrice: { type: 'number', minimum: 0 },
},
},
},
} as const;
The $schema declaration communicates intent. The $id gives the schema a stable identity for references. required controls presence, while properties controls constraints when a property exists. This distinction causes a common bug: defining id under properties does not make it mandatory. The explicit required array does.
Use validator strictness as a schema lint. Ajv's strict mode can surface ignored or ambiguous schema constructs instead of allowing a misspelled keyword to pass unnoticed. Decide separately whether the application wants type coercion or default insertion. For response validation, mutation is usually undesirable. A validator that turns "42" into 42 can hide the exact breaking change the test should catch.
Required, Nullable, Optional, and Absent Are Different Contracts
API discussions often use “optional” loosely. JSON has at least three materially different states: a property is absent, present with null, or present with a value. Consumers may branch differently for each. Encode the intended state instead of accepting all three for convenience.
| Contract phrase | JSON Schema expression | Valid examples | Invalid example |
|---|---|---|---|
| Required string | listed in required, type: 'string' | {"name":"Ada"} | {}, {"name":null} |
| Optional string | not in required, type: 'string' | {}, {"name":"Ada"} | {"name":null} |
| Required nullable string | required, type union string/null | {"name":"Ada"}, {"name":null} | {} |
| Optional nullable string | not required, type union | all three states | number value |
In draft 2020-12, a nullable string can use type: ['string', 'null']. The OpenAPI nullable keyword belongs to particular OpenAPI schema dialects and should not be assumed to work as a generic JSON Schema keyword. This is one reason to identify the source dialect before reusing an OpenAPI fragment in a standalone validator.
Also distinguish an empty collection from an absent one. Returning items: [] tells a consumer the relationship was evaluated and contains no items. Omitting items may mean the relationship was not expanded. If the API makes that distinction, use separate response variants rather than one permissive schema that accepts either.
Build a Reusable Validator That Fails With Evidence
A boolean result is not enough in a test suite. Ajv exposes structured validation errors after a failed call. Convert them into a stable message containing the instance path, schema keyword, and human-readable message. Avoid snapshotting the entire internal error object because library upgrades may change presentation details without changing the contract.
import Ajv2020, { type ErrorObject } from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
const ajv = new Ajv2020({
allErrors: true,
strict: true,
});
addFormats(ajv);
function formatErrors(errors: ErrorObject[] | null | undefined): string {
if (!errors?.length) return 'unknown schema validation error';
return errors
.map((error) => {
const path = error.instancePath || '/';
return `${path}: ${error.message ?? error.keyword}`;
})
.join('\n');
}
export function compileResponseValidator<T>(schema: object) {
const validate = ajv.compile<T>(schema);
return (body: unknown): asserts body is T => {
if (!validate(body)) {
throw new Error(`Response violated JSON Schema:\n${formatErrors(validate.errors)}`);
}
};
}
Compile once, validate many times. Compilation at module setup catches invalid schemas early and avoids repeating work for every test. The generic type helps TypeScript after runtime validation, but it does not prove that the TypeScript interface and JSON Schema are synchronized. They remain two artifacts unless the team generates one from the other with a reviewed toolchain.
Set allErrors: true for test diagnostics when seeing multiple violations reduces iteration. In production request validation, some teams choose fail-fast behavior for performance. Keep those policies separate. Tests are optimized for diagnosis, not request throughput.
Exercise a Real Endpoint Without Hiding HTTP Failures
Supertest can drive an in-process Node HTTP application, which makes it useful for endpoint-level schema tests. Keep schema validation inside the test body or a small custom assertion so the HTTP request and response remain visible.
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import { app } from '../src/app';
import { orderSchema } from './schemas/order.schema';
import { compileResponseValidator } from './schema-validator';
const validateOrder = compileResponseValidator(orderSchema);
describe('GET /orders/:id', () => {
it('returns the public order representation', async () => {
const response = await request(app)
.get('/orders/order-123')
.set('Authorization', 'Bearer test-customer-token');
expect(response.status).toBe(200);
expect(response.headers['content-type']).toMatch(/application\/json/);
validateOrder(response.body);
expect(response.body.id).toBe('order-123');
expect(response.body.items.length).toBeGreaterThan(0);
});
});
If your suite needs a fuller Node API testing setup, the Supertest Node API testing complete guide covers request construction, server lifecycle, authentication, and database isolation. Schema checks should sit on top of that reliable transport harness, not compensate for a test server that leaks state.
Notice that the content-type regex contains an escaped slash in source.
Validate Error Bodies as First-Class API Representations
Teams often validate only 2xx bodies and leave error responses as ad hoc objects. That is backwards for automation clients. Errors are where callers need stable machine-readable codes, trace identifiers, and field-level details. Create separate schemas for authentication failures, validation failures, conflicts, and unexpected server errors when their shapes differ.
export const validationProblemSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
type: 'object',
required: ['type', 'title', 'status', 'errors'],
additionalProperties: false,
properties: {
type: { type: 'string', format: 'uri' },
title: { type: 'string', minLength: 1 },
status: { const: 422 },
traceId: { type: 'string', minLength: 1 },
errors: {
type: 'array',
minItems: 1,
items: {
type: 'object',
required: ['field', 'code'],
additionalProperties: false,
properties: {
field: { type: 'string', minLength: 1 },
code: { enum: ['required', 'invalid_format', 'out_of_range'] },
message: { type: 'string' },
},
},
},
},
} as const;
Do not constrain human-readable messages to exact prose unless the prose itself is contractual. Product copy changes, localization, and punctuation should not break an API integration test. Assert stable error codes and structured locations, then use a lighter semantic assertion for a message if a human must see useful text.
Test at least one representative endpoint for each shared error shape, and unit-test centralized error middleware more exhaustively. Repeating every error-schema permutation on every route inflates runtime without adding proportional confidence.
Use Composition Without Creating an Unreadable Puzzle
JSON Schema offers allOf, anyOf, oneOf, conditional schemas, and references. They can express sophisticated protocols, but composition should make the contract clearer. Use oneOf when exactly one variant must validate, such as a payment represented by either card details or a bank transfer. Use anyOf when multiple branches may legitimately validate. Use allOf to require every listed constraint.
| Keyword | Valid when | Good API use | Frequent mistake |
|---|---|---|---|
allOf | every subschema passes | combine independent constraints | treating it as object inheritance |
anyOf | one or more pass | capability sets that may overlap | using it when ambiguity is harmful |
oneOf | exactly one passes | tagged exclusive variants | branches overlap, so two pass |
not | nested schema fails | forbid a disallowed shape | hiding a simple positive rule |
if/then/else | conditional branch passes | status-dependent required fields | encoding business calculations |
A discriminator field makes variants easier for people and validators to understand:
export const paymentMethodSchema = {
oneOf: [
{
type: 'object',
required: ['kind', 'last4'],
additionalProperties: false,
properties: {
kind: { const: 'card' },
last4: { type: 'string', pattern: '^\\d{4}$' },
},
},
{
type: 'object',
required: ['kind', 'bankName'],
additionalProperties: false,
properties: {
kind: { const: 'bank_transfer' },
bankName: { type: 'string', minLength: 1 },
},
},
],
} as const;
The const values make the branches mutually exclusive. Without them, an object containing both fields might validate against multiple branches and fail oneOf for a surprising reason. When diagnosing a composed-schema failure, inspect errors for each branch, then reduce the body to the smallest object that still fails.
Lock Down Unknown Fields Selectively
additionalProperties: false catches leaked internal fields and unreviewed additions. It is powerful, but applying it mechanically can turn additive evolution into a breaking change for the test suite. Choose based on ownership and consumer expectations.
Use a closed schema for security-sensitive objects, fixed commands, and public DTOs where every field must be reviewed. Consider an open extension object for metadata explicitly designed for forward-compatible additions. A balanced pattern closes known resources but reserves a named map for extensions.
export const eventSchema = {
type: 'object',
required: ['id', 'name', 'extensions'],
additionalProperties: false,
properties: {
id: { type: 'string' },
name: { type: 'string' },
extensions: {
type: 'object',
additionalProperties: true,
},
},
} as const;
What people get wrong is equating strictness with quality. A closed schema is not automatically better if the published compatibility policy permits new response fields. The correct constraint mirrors the actual promise. If unknown fields are allowed, consumers should ignore them, and tests should prove that consumer behavior. If unknown fields can expose secrets, close the schema and add explicit redaction tests.
Test the Test With Positive and Negative Fixtures
A schema that has never rejected anything is unproven. A misspelled keyword, missing required, or broad union can make it accept almost every object. Add small fixtures that deliberately violate one rule at a time. These are unit tests for the contract artifact and run without an HTTP server.
import { describe, expect, it } from 'vitest';
import Ajv2020 from 'ajv/dist/2020';
import { orderSchema } from './order.schema';
const validate = new Ajv2020({ allErrors: true, strict: true }).compile(orderSchema);
describe('orderSchema', () => {
const validOrder = {
id: 'o-1',
status: 'paid',
currency: 'USD',
total: 24.5,
items: [{ sku: 'BOOK-1', quantity: 1, unitPrice: 24.5 }],
};
it('accepts the smallest public order', () => {
expect(validate(validOrder)).toBe(true);
});
it.each([
['missing id', { ...validOrder, id: undefined }],
['string total', { ...validOrder, total: '24.50' }],
['unknown root field', { ...validOrder, internalRiskScore: 7 }],
['empty items', { ...validOrder, items: [] }],
])('rejects %s', (_label, candidate) => {
const body = JSON.parse(JSON.stringify(candidate));
expect(validate(body)).toBe(false);
});
});
Use JSON serialization in this example to model wire behavior: JavaScript properties whose value is undefined disappear from JSON. Add focused cases for nullability, boundary numbers, pattern failures, variant overlap, nested unknown fields, and empty arrays. Keep each mutation obvious so a reviewer can connect the fixture to a contract decision.
Diagnose the Failure Before Weakening the Schema
Consider a CI failure at /items/0/unitPrice: “must be number.” The actual response contains "24.50". Three causes are plausible. A serializer changed decimal values to strings, the API contract always promised strings and the schema is wrong, or a test fixture bypassed production serialization. Do not immediately change the schema to accept both types.
Use this diagnosis sequence:
- Capture the status, content type, failing instance path, and a redacted body.
- Reproduce against the same application revision with a deterministic seed.
- Compare the public API description and consumer parsing behavior.
- Inspect the serialization boundary, not only the controller object.
- Decide which representation is intended, then update implementation or contract.
- Add a negative schema fixture for the rejected representation.
The realistic failure mode is a test that validates an in-memory object before the HTTP framework serializes it. Dates, big integers, decimals, and undefined properties can change at serialization. Endpoint schema tests must validate response.body from the actual HTTP boundary. Unit-level schema tests complement that check but cannot replace it.
Separate Shape Compatibility From Consumer Compatibility
JSON Schema can identify many breaking changes: removing a required response field, changing a type, narrowing an enum, or adding a required request field. It cannot prove that a particular consumer uses the API safely. A consumer might rely on ordering, cross-field relationships, or a sequence of calls that the schema never expresses.
| Change | Response schema result | Consumer risk | Recommended gate |
|---|---|---|---|
| Add optional response field | may fail a closed schema | usually low for tolerant readers | policy review plus consumer tests |
| Remove required response field | fails | high | block |
| Add enum member | old schema may fail | high for exhaustive switches | consumer verification |
| Widen accepted request input | remains valid | generally low | targeted behavior test |
| Add required request field | old requests fail | high | block |
| Change value meaning only | schema may pass | potentially high | semantic and contract scenarios |
For interactions owned by multiple deployable teams, pair shape validation with consumer-driven testing. The Pact contract testing complete guide explains how consumer expectations become provider verification rather than one universal response schema. Use schema tests for representation rules and Pact-style interactions for consumer-specific examples. Neither tool should impersonate the other.
Put Fast Contract Feedback Into CI
Organize checks by cost. Schema artifact tests need no network or database, so run them early. Endpoint tests require application startup and state setup, so run them next. Broader consumer verification can run after the provider build is available. This ordering gives a pull request the fastest specific failure.
name: api-contract-checks
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: npm
- run: npm ci
- run: npm run test:schema
- run: npm run test:api
The workflow uses documented GitHub Actions and npm behavior without assuming a particular test script implementation. Define test:schema and test:api in the repository so local and CI commands match. Pin application dependencies through the lockfile. If the schema lives in a separate repository or registry, record the exact contract revision tested with the build.
Publish a concise test report and preserve a redacted response artifact only when needed. API bodies can contain tokens, personal data, or secrets. A debugging improvement that uploads raw responses may create a security incident. Prefer a sanitizer that retains field names and failing values known to be safe.
A Review Checklist for Durable Schema Suites
Before merging a new response schema, reviewers should be able to answer concrete questions:
- Does the root declare the intended dialect?
- Are public required fields listed explicitly?
- Are optional and nullable states intentional?
- Are objects closed or open according to the compatibility policy?
- Do arrays define item shape and meaningful size constraints?
- Are string formats actually validated by the configured validator?
- Do exclusive variants have unambiguous discriminator values?
- Is the schema compiled once and tested with negative fixtures?
- Does endpoint validation occur after real HTTP serialization?
- Are schema, transport, semantic, and security assertions separate?
- Will the failure output identify an instance path without leaking sensitive data?
- Does CI run the same commands developers can run locally?
If your AI coding agent generates a first schema, give it a representative response, the public API description, the dialect, and the compatibility policy. Then ask it to produce both accepting and rejecting fixtures. Review every required, enum, pattern, and unknown-property decision. Generation is useful for boilerplate, but the contract still represents a product promise that needs human ownership.
Ready-made QA skills can also be installed from qaskills.sh with the qaskills CLI when you want an agent workflow packaged for reuse. Treat that skill as an execution guide, while keeping contract choices and approval in the repository review process.
Frequently Asked Questions
Should every API response have a JSON Schema?
Every stable public representation benefits from an explicit contract, but that does not require a separate handwritten file for every status on every route. Reuse shared error schemas and referenced components where the shapes truly match. Prioritize externally consumed APIs, security-sensitive objects, and responses changed by multiple teams. A temporary internal endpoint may need only focused assertions. The important rule is that schema coverage follows compatibility risk, not raw endpoint count, and that reused schemas do not erase meaningful differences between operations.
Is additionalProperties false always the safest choice?
No. It is safest for catching accidental exposure and undocumented fields, but it can conflict with an API policy that permits additive response properties. Apply it to fixed public DTOs and sensitive objects when new fields require review. Leave a deliberate extension point when forward-compatible metadata is part of the design. The unsafe choice is accidental permissiveness or accidental closure. Document the policy, test consumer tolerance where additions are allowed, and keep explicit redaction assertions for values that must never leave the service.
Can JSON Schema validate business rules such as order totals?
JSON Schema handles structural constraints well, including types, ranges, patterns, required properties, and conditional shapes. It is not a general calculation language for rules such as “total equals the sum of quantity times unit price.” Keep that as a semantic test written in the host language, beside the schema assertion. This separation produces clearer failures and avoids contorting the schema into something reviewers cannot maintain. Validate the body first, then calculate the expected relationship from already typed values.
How should a team update schemas during an intentional breaking change?
Start with an explicit compatibility decision, not an automatic snapshot update. Identify affected consumers, version or coordinate the change according to the API's published policy, and add tests for both the old and new transition behavior where needed. Update the schema, its positive and negative fixtures, endpoint tests, and consumer verification in the same change set or coordinated releases. Record the contract revision used in CI. A schema diff is evidence of structural change, but consumer owners still need to assess semantic and workflow impact.