Release candidate — 1.0.0-rc.1
StitchAPI
GuidesTesting

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 RegExp tests the full URL,
  • a function (req) => boolean is 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) => MockResponse that sees call.index and call.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
.(); // 3

A 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 as match.
  • .callCount(filter?) — how many; assert retry and cache behaviour with it.
  • .lastRequest() — the most recent request, or undefined.
  • .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
.?.; // 404

Testing 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
.?.; // 500

With 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) — a ReadableStream that emits each chunk as its own read, then closes.
  • sseStream(events) — a text/event-stream body, 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) — emits first, then stays open until gate resolves — the long-lived shape a concurrency / long-poll test needs.
  • streamAdapter(body, init?) — a minimal adapter that returns body as the live response body; for routing and a spy, use mockAdapter (it accepts a stream response 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
.?.; // true

A 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 }
.?.; // false

collectStitchEvents 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 waiting

advance(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.

See also

On this page