Skip to main content

Requests & Responses

The test client has a method for every operation in your API definition, named after its operationId:

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

test('lists pets', async () => {
const res = await client.getPets({ query: { limit: 10 } });

expect(res.status).toBe(200);
expect(res.body).toHaveLength(10);
});

See Testing for how to create the client.

Calling operations​

Operation methods take a single request object. Every property is optional:

await client.getPetById({ params: { id: 1 } });

await client.getPets({
query: { limit: 10 },
headers: { 'x-request-id': 'test-1' },
cookies: { session: 'abc123' },
});

await client.createPet({ body: { name: 'Garfield', type: 'cat' } });
OptionDescription
paramsPath parameters, like { id: 1 } for /pets/{id}. Your handlers read them from c.request.params.
queryQuery parameters, like { limit: 10 }.
headersRequest headers.
cookiesCookies, sent in the Cookie header.
bodyThe request body.
contentTypeThe media type to send the body as, when the operation accepts more than one.
exampleFill in the request from the examples in your spec. See Generating requests from examples.
securityResults to use instead of calling your security handlers. See Testing security.
validateResponseSet to false to skip checking this response against your spec. See Contract testing.

The client builds the HTTP request from the operation in your spec, the same way a real API client would:

  • path parameters are filled into the path template and URL-encoded, and the apiRoot is prepended
  • query parameters are serialized with the style and explode defined for each parameter, so an array declared with explode: false is sent as tags=cat,dog
  • the body is encoded as the operation's media type, preferring application/json, and content-type is set for you. Form bodies are encoded as application/x-www-form-urlencoded or multipart/form-data. Strings and Buffers are sent as they are.

res.request shows what was actually sent:

const res = await client.getPets({ query: { limit: 10 } });

expect(res.request.method).toBe('GET');
expect(res.request.url).toBe('/pets?limit=10');

Calling an operation that doesn't exist, or leaving out a path parameter, throws:

Error: Unknown operationId "getPetByID". Did you mean "getPetById"?
Error: Missing path parameter "id" for getPetById (GET /pets/{id})

The response​

Operation methods resolve to a response object:

PropertyDescription
statusThe HTTP status code.
headersThe response headers, with lowercase names. Several values for the same header are joined with , .
bodyThe response body. JSON is parsed. Other text is a string, binary content is a Buffer, and an empty body is undefined.
operationIdThe operationId of the operation the request was routed to. undefined when no operation matched.
operationThe matched Operation object.
contextThe Context object openapi-backend built for the request: the parsed request, the request validation result and the security handler results. undefined when the request never reached api.handleRequest().
requestThe request the client sent: method, path, url, query, headers and body.

The client resolves for every status code. A 404 is a response to assert on, not an error:

test('returns 404 for unknown pets', async () => {
const res = await client.getPetById({ params: { id: 999 } });

expect(res.status).toBe(404);
expect(res.body.message).toBe('Pet not found');
});

context lets you see what your handler saw. For example, with coerceTypes enabled, query parameters arrive as numbers:

const res = await client.getPets({ query: { limit: 10 } });

expect(res.context.request.query).toEqual({ limit: 10 });

operation, context and request aren't enumerable, so they stay out of the way in toEqual() and toMatchObject() diffs.

note

Every response is checked against the responses documented for the operation before the method resolves. If your API responds with something your spec doesn't describe, the call throws and the test fails. See Contract testing.

Default request options​

client.with() returns a new client that adds options to every request. Use it for things like authentication and tenant headers:

const alice = client.with({ headers: { authorization: `Bearer ${token}` } });
const aliceAtAcme = alice.with({ headers: { 'x-tenant': 'acme' } });

await aliceAtAcme.getPets(); // sends both headers

Options are merged, and options passed to a single request win. Set an option to undefined to remove it. The original client doesn't change, so it's safe to share one client between tests.

You can also pass defaults straight to createTestClient():

export const client = createTestClient(api, {
app,
headers: { 'x-tenant': 'acme' },
});

Generating requests from examples​

Set example: true and the client fills in the request from your spec:

const res = await alice.createPet({ example: true });
// POST /pets
// { "name": "Garfield", "type": "cat" }

Values come from the first place that has one:

  1. the example or examples of the parameter or request body
  2. the example or examples of its schema
  3. a value generated from the schema with mock-json-schema, which uses default and enum values when your schema has them

When there are several examples, the first one is used. Path parameters, required parameters and the request body are always filled in. Optional parameters are only included if your spec has an example for them.

Pick a named example with its name:

await alice.createPet({ example: 'odie' });
// { "name": "Odie", "type": "dog" }

Anything else you pass is deep merged into the example. Set a property to undefined to leave it out. Arrays replace the example's arrays. This makes "a valid request, except for one thing" tests short and readable:

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

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

test('requires a type', async () => {
const body = { type: undefined };
const res = await alice.createPet({ example: true, body });

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

The generated request is checked against your API's own validation before your overrides are applied. If it doesn't pass, for example because a schema has a pattern the generator can't satisfy, the client throws and tells you what needs an example:

Error: The example request for createCode doesn't pass validation:

body.code must match pattern "^[A-Z]{3}$"

Add an example for it to your API definition, or pass the values yourself.
tip

Use example to cut out noise, not to hide what a test is about. When the request is the point of the test, write it out.

Raw requests​

client.request() sends a request you write yourself, without looking up an operation. Use it to test routing, send requests your types won't allow, or call endpoints that aren't in your spec:

await client.request('GET /pets/meta');
await client.request('GET /pets?limit=many');
await client.request('POST /pets', {
headers: { 'content-type': 'application/json' },
body: '{ "name": "Garfield", ',
});

The first argument is the method and path, with an optional query string. The options are the same as for operation methods, except params and example. The response still has operationId and context, and is still checked against your spec if the router matched an operation.

Calling operations by name​

client.call() calls an operation by its operationId. It's handy for table-driven tests:

test.each(['getPets', 'getPetsMeta'])('%s is public', async (operationId) => {
const res = await client.call(operationId);

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

It's also how you call an operation whose operationId clashes with one of the client's own methods: call, request, with or api.

TypeScript​

Pass the Operations type generated by openapi typegen --backend to createTestClient():

test/client.ts
import type { Operations } from '../src/types/openapi';

export const client = createTestClient<Operations>(api, { app });

Operation methods are then typed from your spec:

const res = await client.getPetById({ params: { id: 1 } });
res.body; // Pet | Error

// ❌ Property 'getPetByID' does not exist
await client.getPetByID({ params: { id: 1 } });

// ❌ Property 'type' is missing
await client.createPet({ body: { name: 'Garfield' } });

With example, request bodies and parameters only need the parts you want to change.

To send an invalid request on purpose, mark it with @ts-expect-error, which also documents your intent:

// @ts-expect-error: id must be a number
const res = await client.getPetById({ params: { id: 'garfield' } });

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

Or use client.request(), which isn't typed by operation.