@toad-contracts/testing
Testing utilities for mocking HTTP responses defined with
@toad-contracts/core (or any adapter built on it,
such as @toad-contracts/valibot). Two helpers register mock rules from a contract and validate the
response body through the contract's Standard Schema:
| Helper | Backend | Use case |
|---|---|---|
ApiContractMockttpHelper |
mockttp | server-side integration tests |
MswHelper |
msw | frontend tests |
mockttp and msw are optional peer dependencies; install whichever you use.
Table of contents
ApiContractMockttpHelper
Mock HTTP responses in mockttp-based tests.
Setup
import { getLocal } from "mockttp";
import { ApiContractMockttpHelper } from "@toad-contracts/testing";
const mockServer = getLocal();
const helper = new ApiContractMockttpHelper(mockServer);
beforeEach(() => mockServer.start());
afterEach(() => mockServer.stop());
mockResponse
Registers a mock rule for the given contract. responseStatus is the concrete numeric HTTP status
code the mock sends. It also selects which schema is used: the helper looks up the contract entry
with exact → range → 'default' precedence, so a contract with only a '2xx' key accepts any
responseStatus in 200-299.
import { defineApiContract } from "@toad-contracts/core";
import { withObjectKeys } from "@toad-contracts/valibot";
import { object, string } from "valibot";
const contract = defineApiContract({
method: "get",
pathResolver: () => "/users",
responsesByStatusCode: { 200: object({ id: string() }) },
});
await helper.mockResponse(contract, {
responseStatus: 200,
responseJson: { id: "1" },
});
The body is validated and stripped through the contract's Standard Schema before being sent. Path
params are required when the contract declares requestPathParamsSchema:
const getUser = defineApiContract({
method: "get",
requestPathParamsSchema: withObjectKeys(object({ userId: string() })),
pathResolver: ({ userId }) => `/users/${userId}`,
responsesByStatusCode: { 200: object({ id: string() }) },
});
await helper.mockResponse(getUser, {
pathParams: { userId: "42" },
responseStatus: 200,
responseJson: { id: "42" },
});
Response kinds
params is a discriminated union on responseStatus. The body fields required for a status code
are inferred from the contract's response entry for that code:
| Body declared by the entry | Required field |
|---|---|
| a Standard Schema (JSON) | responseJson: StandardSchemaV1.Input |
blobBody() / blobResponse(ct) |
responseBlob: string | Uint8Array |
sseBody() / sseResponse(...) |
events: { event; data }[] |
noBodyResponse() |
(none) |
A content map declaring several bodies asks for one field per kind, so a dual-mode (JSON + SSE)
status code requires both responseJson and events, and the mock answers by accept the way the
real route does. An entry that also sets allowNoBody: true makes every body field optional: omit
them all to mock the empty response, or supply just one to mock only that body.
When a status code declares several variants of one kind (e.g. application/json and
application/json+01), pass contentType to name the media type the mock should serve. It is
matched the way the client matches a response content-type — parameters stripped, case ignored —
and must name a media type the status code declares; anything else throws, so a typo fails the test
instead of silently mocking an empty body. Without it, the first media type the contract declares
wins.
await helper.mockResponse(getReport, {
responseStatus: 200,
contentType: "application/pdf",
responseBlob: "%PDF-",
});
For SSE contracts, the mock replies with a text/event-stream body built from events:
import { sseResponse } from "@toad-contracts/core";
const sse = defineApiContract({
method: "get",
pathResolver: () => "/events/stream",
responsesByStatusCode: {
200: sseResponse({ completed: object({ totalCount: number() }) }),
},
});
await helper.mockResponse(sse, {
responseStatus: 200,
events: [{ event: "completed", data: { totalCount: 1 } }],
});
For dual-mode contracts — one status code declaring both application/json and
text/event-stream — the mock routes on the request's Accept header: text/event-stream
receives the SSE stream, everything else receives the JSON body. Both events and responseJson
are required.
Range and wildcard status keys
Contracts may use range keys ('1xx'-'5xx') or 'default' instead of exact codes. Pass any
concrete numeric code covered by that range as responseStatus; the helper resolves the entry with
the same exact → range → 'default' precedence as the runtime client.
Type safety
MockResponseParams<TContract> is exported for typing the params object separately:
import type { MockResponseParams } from "@toad-contracts/testing";
function mockUser(params: MockResponseParams<typeof getUserContract>) {
return helper.mockResponse(getUserContract, params);
}
MswHelper
The msw counterpart. Construct it with a base URL, then register handlers on an msw SetupServer.
mockResponse takes the same MockResponseParams as the mockttp helper.
import { setupServer } from "msw/node";
import { MswHelper } from "@toad-contracts/testing";
const server = setupServer();
const helper = new MswHelper("http://localhost:8080");
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
mockResponse
helper.mockResponse(contract, server, {
responseStatus: 200,
responseJson: { id: "1" },
});
mockSseStream
Returns an SseEventController for emitting SSE events on demand instead of all at once. Works with
SSE and dual-mode contracts; for dual-mode contracts, non-SSE requests receive responseJson.
const controller = helper.mockSseStream(sseContract, server);
const response = await fetch("http://localhost:8080/events/stream");
controller.emit({ event: "completed", data: { totalCount: 1 } });
controller.close();
Event names and data shapes are inferred from the contract's SSE schemas.
formatSseResponse
A standalone helper for manual SSE body formatting:
import { formatSseResponse } from "@toad-contracts/testing";
const body = formatSseResponse([{ event: "completed", data: { totalCount: 1 } }]);
// "event: completed\ndata: {\"totalCount\":1}\n\n"
validateResponseBody
Validates a value against a Standard Schema and returns the parsed output (unknown keys stripped,
transforms applied), the synchronous validation the mock helpers use internally. It is re-exported
for tests that need the same check directly. A schema that validates asynchronously is unsupported
and throws a TypeError, since the mock helpers buffer the body synchronously.
import { validateResponseBody } from "@toad-contracts/testing";
import { object, string } from "valibot";
validateResponseBody(object({ id: string() }), { id: "1", extra: "dropped" }); // { id: "1" }