Skip to main content

Testing Validation

openapi-backend validates path, query, header and cookie parameters and request bodies against the schemas in your API definition. Validation tests make sure invalid requests are rejected before they reach your handlers, and that valid requests aren't.

Invalid requests​

Send a request that breaks a rule in your spec, and check that it was rejected. res.context.validation has the result of request validation:

test/createPet.test.ts
import { describe, test, expect } from 'vitest';
import { client } from './client';

const alice = client.with({ security: { jwt: { sub: 'alice' } } });

describe('createPet', () => {
test('requires a name', async () => {
const res = await alice.createPet({ body: { type: 'cat' } });

expect(res.status).toBe(400);
expect(res.context.validation.errors).toContainEqual(
expect.objectContaining({ keyword: 'required', params: { missingProperty: 'name' } }),
);
});

test('only accepts cats and dogs', async () => {
const nemo = { name: 'Nemo', type: 'fish' };
const res = await alice.createPet({ body: nemo });

expect(res.status).toBe(400);
expect(res.context.validation.errors).toContainEqual(
expect.objectContaining({ instancePath: '/requestBody/type', keyword: 'enum' }),
);
});

test('rejects unknown properties', async () => {
const res = await alice.createPet({
body: { name: 'Garfield', type: 'cat', age: 5 },
});

expect(res.status).toBe(400);
expect(res.context.validation.valid).toBe(false);
});
});

createPet requires authentication, and openapi-backend checks security before it validates a request. That's why these requests are sent as alice: an anonymous request would be rejected as unauthorized before validation even runs.

Reading validation errors​

res.context.validation is a ValidationResult object: valid tells whether the request passed, and errors is a list of Ajv errors. Their instancePath points into the request:

instancePathPoints to
/path/idthe path parameter id
/query/limitthe query parameter limit
/header/x-api-keythe header x-api-key
/cookie/sessionthe cookie session
/requestBody/owner/namea property in the body
/requestBody/tags/0an item in an array

Missing properties are reported at the object they're missing from, with the property in params.missingProperty. Ajv stops at the first error unless you set allErrors in ajvOpts.

Valid requests​

res.context.validation.valid tells whether a request passed validation, whatever your handler did with it afterwards. That lets you test what your spec accepts without setting up data for your handlers:

test.each([1, 50, 100])('accepts limit=%i', async (limit) => {
const res = await client.getPets({ query: { limit } });

expect(res.context.validation.valid).toBe(true);
});

test.each([0, 101])('rejects limit=%i', async (limit) => {
const res = await client.getPets({ query: { limit } });

expect(res.status).toBe(400);
expect(res.context.validation.valid).toBe(false);
});

Starting from a valid request​

Most validation tests send a valid request with one thing wrong. With example: true, the client starts from the examples in your spec, and you only write the part that matters:

test('rejects an empty name', async () => {
const res = await alice.createPet({ example: true, body: { name: '' } });

expect(res.status).toBe(400);
expect(res.context.validation.errors).toContainEqual(
expect.objectContaining({ instancePath: '/requestBody/name', keyword: 'minLength' }),
);
});

Because your spec is available in tests, you can even generate the cases from it. This tests every required property of the createPet body, and keeps testing them when you add more:

import { api, client } from './client';

const alice = client.with({ security: { jwt: { sub: 'alice' } } });

const { requestBody } = api.getOperation('createPet');
const { required } = requestBody.content['application/json'].schema;

test.each(required)('createPet requires %s', async (property) => {
const body = { [property]: undefined };
const res = await alice.createPet({ example: true, body });

expect(res.status).toBe(400);
expect(res.context.validation.errors).toContainEqual(
expect.objectContaining({ params: { missingProperty: property } }),
);
});

Malformed requests​

To send a request your types won't allow, like a body that isn't valid JSON, use client.request():

test('rejects malformed JSON', async () => {
const res = await alice.request('POST /pets', {
headers: { 'content-type': 'application/json' },
body: '{ "name": "Garfield", ',
});

expect(res.status).toBe(400);
});

Whether openapi-backend ever sees a malformed body depends on your app. In the example Express app, express.json() rejects it before the request reaches api.handleRequest(), so this test checks your app's own error handling, and res.context is undefined. When your app passes the raw body to openapi-backend, like a Lambda handler does, the request fails validation with a parse error instead:

expect(res.context.validation.errors).toContainEqual(expect.objectContaining({ keyword: 'parse' }));

Formats and Ajv options​

Tests use your own api instance, so custom formats from customizeAjv, ajvOpts and coerceTypes apply in tests exactly as they do in production.

Check the status, too​

Without a validationFail handler or strict: true, requests that fail validation still reach your operation handlers. res.context.validation.valid is false either way, so assert on res.status as well: a test like the ones above fails if an invalid request gets a 201. See Security Best Practices.

Response validation​

Responses are validated too, automatically, on every request. See Contract testing.