Testing
Mock the transport to test a stitch definition, or swap in a fake stitch to test code that calls one — from stitchapi/testing.
There are two things to test, and a tool for each. To test a stitch
definition — does it route, retry, validate, time out the way you declared? —
inject a mock Adapter and drive the real engine. To test code that calls a
stitch — a service, a handler, a loader — replace the stitch with a fake
Stitch so no network is involved at all.
Both tools ship from the stitchapi/testing subpath. It is framework-agnostic
and browser-safe — no vitest/jest imports, no node:* modules — so it runs
in any test runner.
This guide covers the mocking kit for application authors. The
verify*Contract functions in the same entry — for proving a custom
adapter, store, or sink implements its seam — are a separate reference:
Conformance kits.
Testing a stitch definition — mockAdapter
The transport is a seam: a stitch sends through its
adapter, defaulting to fetch. Pass a mock adapter instead and the rest of
the engine runs for real — URL building, retry, timeout, validation, the event
stream. Injecting an adapter beats monkeypatching global fetch: it is scoped
to the stitch under test, typed, and comes with a request spy.
mockAdapter(routes, opts?) takes one route or a list. The first route whose
method + match accept a request answers it.
import { } from 'stitchapi';
import { } from 'stitchapi/testing';
import { } from 'zod';
const = ([
{
: 'GET',
: '/users/42',
: { : { : 42, : 'Ada' } },
},
]);
const = ({
: 'https://api.test',
: '/users/{id}',
: .({ : .(), : .() }),
: ,
});
const = await ({ : { : 42 } });
// user: { id: 42, name: 'Ada' } — validated against `output`
.('/users/42'); // 1
.()?.; // 'GET'Matching
A route's match selects which requests it answers:
- a string matches the URL pathname (or, failing that, any substring of the full URL),
- a
RegExptests the full URL, - a function
(req) => booleanis a predicate.
Omit match (and method) and the route catches every request.
Responding
respond is one of three shapes:
- a single
MockResponse— one fixed reply. Its fields:status,headers,body,stream,delayMs,retryAfter. - an array of them — one per call, the last entry repeating, which is how you drive a retry sequence,
- a function
(call) => MockResponsethat seescall.indexandcall.req— for pagination.
body is the already-decoded body: an object is delivered as-is. delayMs is
abortable, so a stitch timeout cancels it just like a real slow endpoint.
A status sequence drives retry, and the call spy proves how many attempts happened:
import { } from 'stitchapi';
import { } from 'stitchapi/testing';
const = ({
: '/flaky',
: [{ : 503 }, { : 503 }, { : { : true } }],
});
const = ({
: 'https://api.test',
: '/flaky',
: ,
: { : 3, : [503], : 1 },
});
await (); // { ok: true } — the third reply
.(); // 3A function responder sees a per-call index, which is all you need to test
pagination:
import { } from 'stitchapi';
import { } from 'stitchapi/testing';
const = ({
: '/items',
: ({ }) => ({ : { : } }),
});
const = ({
: 'https://api.test',
: '/items',
: ,
});
await (); // { page: 0 }
await (); // { page: 1 }The request spy
Every mock adapter records what it received:
.calls(filter?)— the requests, optionally filtered by the same string/regex/predicate asmatch..callCount(filter?)— how many; assert retry and cache behaviour with it..lastRequest()— the most recent request, orundefined..reset()— forget everything and reset each route's call counter.
Unmatched requests
By default an unmatched request fails the test loudly — opts.onUnmatched is
'throw'. Pass a status number to reply with that bare status instead:
import { } from 'stitchapi';
import { } from 'stitchapi/testing';
const = (
{ : '/known', : { : {} } },
{ : 404 },
);
const = ({ : 'https://api.test/missing', : });
const = await .();
.; // false
.?.; // 404Testing code that calls a stitch — stubStitch / failStitch
When the unit under test is a function that takes a stitch and you do not care
about the transport, hand it a fake. stubStitch and failStitch return a
conformant Stitch: callable, with .safe/.unwrap/.stream/.with, passing
isStitch, plus a call spy — but with no network behind it.
stubStitch(value | (input) => value, opts?) resolves to the value (or a
function of the call input). The spy exposes .calls, .callCount, and
.reset():
import { type , } from 'stitchapi';
import { } from 'stitchapi/testing';
// The code under test depends on a stitch, not on the network.
async function (: ) {
return ({ : { : 42 } });
}
const = ({ : 42, : 'Ada' });
(); // true — it's a real Stitch shape
await ();
.; // 1
.[0]; // { params: { id: 42 } }failStitch(error, opts?) is the failure twin. Pass a message, an
{ status, message } shape, or a ready StitchError. await/.unwrap()
reject with a StitchError; .safe() resolves to { ok: false }:
import { } from 'stitchapi';
import { } from 'stitchapi/testing';
const = ({ : 500, : 'boom' });
// await rejects with a StitchError…
try {
await ();
} catch () {
if ( instanceof ) {
.; // 'boom'
.; // 500
}
}
// …and .safe() never throws.
const = await .();
.; // false
.?.; // 500With NestJS these pair with overrideProvider(token).useValue(...): stub
the stitch provider in a test module and the service under test never knows.
See Integrations → NestJS.
Streaming bodies and collectStitchEvents
To test the stream and sse
surfaces you need a live response body. The stream builders produce one, and
collectStitchEvents drains the result so you can assert on it.
streamOf(chunks)— aReadableStreamthat emits each chunk as its own read, then closes.sseStream(events)— atext/event-streambody, one well-formed frame per event (a bare string is shorthand for{ data }).streamThenError(chunks, err?)— emits the chunks, then errors instead of closing — for a mid-stream transport failure.gatedStream(first, gate)— emitsfirst, then stays open untilgateresolves — the long-lived shape a concurrency / long-poll test needs.streamAdapter(body, init?)— a minimal adapter that returnsbodyas the live response body; for routing and a spy, usemockAdapter(it accepts astreamresponse too).
collectStitchEvents(source) drains a StitchResult (or a .stream()
generator) into { types, deltas, drifts, result, error, done, events } — the
canonical way to assert on a stream:
import { } from 'stitchapi/stream';
import { , , } from 'stitchapi/testing';
const = ({
: '/s',
: { : (['ab', 'cd']) },
});
const = ({ : 'https://api.test/s', : });
const = await (.());
..('delta'); // true
..; // 2
.?.; // trueA streamThenError body surfaces as an error event with the deltas seen so
far preserved:
import { } from 'stitchapi/stream';
import {
,
,
,
} from 'stitchapi/testing';
const = ({
: '/s',
: { : (['ab']) },
});
const = ({ : 'https://api.test/s', : });
const = await (.());
..; // 1 — the chunk before the failure
.; // { message, status }
.?.; // falsecollectStitchEvents also drains a plain call (the function surface synthesizes
a start → result → done spine), so it works against a stub too — handy for
asserting that your code consumed the stream it was given.
Controlling time — manualClock
Retry backoff, throttle pacing, and the per-attempt timeout are time-driven, so
testing them used to mean real waiting. Inject a manualClock() as clock and
drive that time by hand with advance(ms) — no real delay, no fake-timer library:
import { } from 'stitchapi';
import { , } from 'stitchapi/testing';
const = ();
const = ({
: '/flaky',
: [{ : 503 }, { : { : true } }],
});
const = ({
: 'https://api.test',
: '/flaky',
: ,
: { : 2, : [503], : 10_000 },
,
});
const = .();
await .(0); // run the first attempt (503)
await .(10_000); // fire the backoff → the retry succeeds
await ; // { ok: true } — with zero real waitingadvance(ms) fires every backoff/pacing/timeout due at or before the new time, in
order, and pending() reports the timers still waiting. The default is the real
systemClock (exported from the main entry), so this changes nothing until you
inject a clock; you can also supply your own object implementing Clock. Note that
timeout.total and event timestamps stay on wall-clock.