npm.io
1.0.0 • Published yesterday

@toad-contracts/testing

Licence
MIT
Version
1.0.0
Deps
2
Size
54 kB
Vulns
0
Weekly
0

@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" }

Keywords