Skip to main content
Back to Blog
API Testing
2026-08-07

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 layerQuestion it answersTypical assertionSchema's role
TransportDid HTTP behave as expected?status, content type, cache headerNone
StructureCan consumers safely parse the body?required fields, types, unionsPrimary
SemanticsAre the values correct for this scenario?total equals line-item sumSupporting only
SecurityIs access constrained correctly?tenant isolation, redactionSupporting only
Consumer compatibilityCan a known consumer still interact?provider verificationComplements 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 phraseJSON Schema expressionValid examplesInvalid example
Required stringlisted in required, type: 'string'{"name":"Ada"}{}, {"name":null}
Optional stringnot in required, type: 'string'{}, {"name":"Ada"}{"name":null}
Required nullable stringrequired, type union string/null{"name":"Ada"}, {"name":null}{}
Optional nullable stringnot required, type unionall three statesnumber 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.

KeywordValid whenGood API useFrequent mistake
allOfevery subschema passescombine independent constraintstreating it as object inheritance
anyOfone or more passcapability sets that may overlapusing it when ambiguity is harmful
oneOfexactly one passestagged exclusive variantsbranches overlap, so two pass
notnested schema failsforbid a disallowed shapehiding a simple positive rule
if/then/elseconditional branch passesstatus-dependent required fieldsencoding 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:

  1. Capture the status, content type, failing instance path, and a redacted body.
  2. Reproduce against the same application revision with a deterministic seed.
  3. Compare the public API description and consumer parsing behavior.
  4. Inspect the serialization boundary, not only the controller object.
  5. Decide which representation is intended, then update implementation or contract.
  6. 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.

ChangeResponse schema resultConsumer riskRecommended gate
Add optional response fieldmay fail a closed schemausually low for tolerant readerspolicy review plus consumer tests
Remove required response fieldfailshighblock
Add enum memberold schema may failhigh for exhaustive switchesconsumer verification
Widen accepted request inputremains validgenerally lowtargeted behavior test
Add required request fieldold requests failhighblock
Change value meaning onlyschema may passpotentially highsemantic 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.