Release candidate — 1.0.0-rc.7
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, delay, retryAfterSeconds.
  • 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. delay is abortable, so a stitch timeout cancels it just like a real slow endpoint.

Cancellation is honoured on every route, delay or not: a request whose signal is already aborted rejects without being answered, matching what a real transport does (and what conformance.adapter requires of one). Nothing is sent, so such a request is absent from the spy and leaves a response sequence at its current position.

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(filter?), .callCount(filter?), 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, the per-attempt timeout and OAuth2 token expiry 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.

Token expiry

oauth2 decides token freshness on the same clock, so "does my client refresh before the token expires?" is a test you can write — advance past expires_in minus refresh.skew and assert the second fetch:

import {  } from 'stitchapi';
import {  } from 'stitchapi/auth';
import { ,  } from 'stitchapi/testing';

const  = ();
const  = ([
    {
        : 'POST',
        : '/token',
        : ({  }) => ({
            : { : `T${ + 1}`, : 60 },
        }),
    },
    { : 'GET', : '/data', : { : { : true } } },
]);

const  = ({
    : 'https://api.test',
    : '/data',
    : ,
    ,
    : ({
        : 'https://api.test/token',
        : { : 'cid', : 'csecret' },
        : ,
    }),
});

await ();
.('/token'); // 1

await .(600_000); // ten virtual minutes, past a 60s token
await ();
.('/token'); // 2 — refreshed, with zero real waiting

What the clock drives, and what it does not

The clock owns control-flow time — the waiting and the deciding — not bookkeeping. That boundary is deliberate, and knowing where it falls is the difference between a test that asserts something and one that only looks like it does — a distinction the mock that passed for six months works through, along with the drift a fixture can never show you because it never changes:

FeatureUnder manualClock
retry backoffvirtualadvance fires each backoff
retry.respect delaysvirtual — a parsed Retry-After is a wait
throttle ratevirtual — spacing is exact, waited reports it
throttle concurrencyvirtual — a holder releases on virtual time
circuit.cooldownvirtualadvance past it opens the probe
timeout.eachvirtual — the per-attempt clamp fires on it
OAuth2 token expiryvirtualexpires_in minus refresh.skew
AWS SigV4 signing datevirtualx-amz-date (@stitchapi/aws-sigv4)
timeout.totalwall-clock — the budget deadline is wall-anchored
cache.ttlwall-clock — cache expiry is a store concern
memoryStore TTLwall-clock — same
Event at / done.mswall-clock — timestamps are cosmetic
paginateno time to drive

The four core wall-clock rows are decisions, not gaps — ADR 0010 scoped the clock to control flow on purpose. timeout.total's budget is anchored to a wall-clock deadline, so virtual sleeps never drain it and it does not stop a virtual-time retry loop: assert a total budget against the real clock, or assert attempt counts instead. Store and cache TTLs are a separate concern (expiry, not scheduling), and threading the clock through ~30 event builders to move a cosmetic timestamp would buy no determinism.

manualClock() starts at epoch 0. A Retry-After given as an HTTP-date is honoured relative to the clock, so a fixture carrying a real date resolves to a wait of roughly twenty thousand days — which presents as a hang, not a failure. Use retryAfterSeconds with a number in fixtures, or seed the clock with a realistic epoch: manualClock(Date.now()). The same seeding applies to SigV4 — an epoch-0 clock stamps x-amz-date as 19700101T000000Z, which a real AWS endpoint answers with RequestTimeTooSkewed.

See also

On this page