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' } });
| Option | Description |
|---|---|
params | Path parameters, like { id: 1 } for /pets/{id}. Your handlers read them from c.request.params. |
query | Query parameters, like { limit: 10 }. |
headers | Request headers. |
cookies | Cookies, sent in the Cookie header. |
body | The request body. |
contentType | The media type to send the body as, when the operation accepts more than one. |
example | Fill in the request from the examples in your spec. See Generating requests from examples. |
security | Results to use instead of calling your security handlers. See Testing security. |
validateResponse | Set 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
apiRootis prepended - query parameters are serialized with the
styleandexplodedefined for each parameter, so an array declared withexplode: falseis sent astags=cat,dog - the body is encoded as the operation's media type, preferring
application/json, andcontent-typeis set for you. Form bodies are encoded asapplication/x-www-form-urlencodedormultipart/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:
| Property | Description |
|---|---|
status | The HTTP status code. |
headers | The response headers, with lowercase names. Several values for the same header are joined with , . |
body | The response body. JSON is parsed. Other text is a string, binary content is a Buffer, and an empty body is undefined. |
operationId | The operationId of the operation the request was routed to. undefined when no operation matched. |
operation | The matched Operation object. |
context | The 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(). |
request | The 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.
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:
- the
exampleorexamplesof the parameter or request body - the
exampleorexamplesof its schema - a value generated from the schema with mock-json-schema, which uses
defaultandenumvalues 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.
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():
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.