Skip to content

services mock

Eugene Lazutkin edited this page Jul 3, 2026 · 4 revisions

Mock

io.mock intercepts requests with your own handlers before they reach the network. It composes with the real pipeline — retries, cache, and decoding still apply — so a mock behaves like an actual server, no HTTP server required.

import io from 'double-meh';

io.mock('https://example.com/a', () => ({mocked: true}));
await io.get('https://example.com/a'); // {mocked: true}
io.mock.clear();

Registering a handler auto-attaches the service, so you rarely call attach() yourself.

Matchers

io.mock(matcher, handler) accepts four matcher shapes:

  • an exact URL string — canonicalized (sorted query, dropped fragment), so query order does not matter,
  • a trailing-* prefix string,
  • a RegExp tested against the URL,
  • a function (request, ctx) => boolean.
io.mock('https://example.com/exact', () => ({ok: true}));
io.mock('https://example.com/users/*', request => ({url: request.url}));
io.mock(/\/things\/\d+$/, () => ({matched: true}));
io.mock(
  request => request.method === 'POST',
  () => ({posted: true})
);

Handler return values

A handler receives (request, ctx). Return a Response to use it verbatim, or return any value to have it serialized as a 200 application/json body.

io.mock(
  'https://example.com/plain',
  () => new Response('hi', {status: 201, headers: {'content-type': 'text/plain'}})
);
const env = await io.full.get('https://example.com/plain');
env.status; // 201, env.data === 'hi'

Composing with the pipeline

A mock sits at the transport end, so the retry and cache services wrap it. A mock that returns 503 then 200 is retried just like a real flaky endpoint.

let n = 0;
io.mock(
  'https://example.com/flaky',
  () =>
    new Response(JSON.stringify({ok: true}), {
      status: ++n < 2 ? 503 : 200,
      headers: {'content-type': 'application/json'}
    })
);
await io.get('https://example.com/flaky', null, {retry: {retries: 2, initDelay: 0}}); // retried, then {ok: true}

Unmatched requests fall through to the real transport. A request can also opt out of mocking entirely with mock: false — it goes straight through even when a matcher would hit.

await io.get('https://example.com/a', null, {mock: false}); // bypasses all mocks

Removing and clearing

Call io.mock(matcher) with the handler omitted to remove a single matcher. io.mock.clear() removes every mock and detaches the service; io.mock.detach() / io.mock.attach() toggle it without dropping registrations.

io.mock('https://example.com/a'); // remove just this one
io.mock.clear(); // remove all + detach

Server-free test harness

Because a function matcher can match everything, a single catch-all mock becomes a stand-in server. The test suite's serve() helper does exactly this:

const serve = handler => io.mock(() => true, handler);

// in a test:
let calls = 0;
serve(() => json({n: ++calls}));
await io.get('https://example.com/anything'); // handled in-process
io.mock.clear();

The handler is a plain function of (request, ctx), so it can branch on method, URL, or headers to emulate whatever server behavior a test needs.

See also

Clone this wiki locally