mock

js/test/mock.ts

fino:test/mock — scoped fetch test doubles.

This module is intentionally fetch-only for this release baseline. It does not provide timers, module mocks, filesystem mocks, or a generic spy/stub API.

The API is closure-scoped on purpose:

await mockFetch(async (mock) => {
  mock.get('https://example.com/data').reply(200, 'ok');
  const res = await fetch('https://example.com/data');
});

await mockFetch('https://collector.example', async (mock) => {
  mock.post('/v1/traces').header('content-type', /json/).reply(202);
});

The original global is always restored in finally, even if the callback throws or an expectation fails.

Types

type FetchInput = string | URL | Request

Input accepted by scoped fetch mocks.

type MockFetchInit = { method?: string; headers?: unknown; body?: unknown; signal?: unknown; }

Minimal fetch init shape captured by scoped fetch mocks.

type HeaderMatcher = string | RegExp | ((value: string | null, call: MockFetchCall) => boolean)

Matcher accepted by MockFetchExpectation.header().

type BodyMatcher = string | Uint8Array | RegExp | ( ( body: Uint8Array, text: string, call: MockFetchCall ) => boolean )

Matcher accepted by MockFetchExpectation.body().

type MockResponseFactory = Response | ((call: MockFetchCall) => Response | Promise<Response>)

Static or request-dependent response used by replyWith().

Interfaces

interface MockFetchCall {

Captured fetch call passed to mock matchers and response factories.

Calls are recorded before expectation matching, so a failed expectation still appears in MockFetchScope.calls. body contains the raw bytes and text is decoded with UTF-8 for convenient string/RegExp matching.

import { mockFetch, type MockFetchCall } from 'fino:test/mock';

await mockFetch(async (mock) => {
  mock.post('https://api.example/items')
    .replyWith((call: MockFetchCall) => new Response(call.text));
  await fetch('https://api.example/items', { method: 'POST', body: 'x' });
});

Properties

callIndex: number

One-based call number for this scoped mock.

const index = call.callIndex;
input: FetchInput

Original fetch() input value.

const originalInput = call.input;
init: MockFetchInit | undefined

Original fetch() init object, if provided.

const originalInit = call.init;
request: Request

Normalized Request constructed from input and init.

const method = call.request.method;
url: URL

Parsed request URL.

const pathname = call.url.pathname;
method: string

Uppercase request method.

if (call.method === 'POST') {
  // inspect call.body
}
headers: Headers

Headers from the normalized request.

const token = call.headers.get('authorization');
body: Uint8Array

Raw request body bytes.

const size = call.body.byteLength;
text: string

UTF-8 decoded request body.

const payload = JSON.parse(call.text || '{}');

Classes

class MockFetchExpectation {

Chainable expectation for one mocked fetch call pattern.

Expectations are matched in registration order. Add header/body matchers, adjust the expected call count, then provide a response with reply() or replyWith().

import { mockFetch } from 'fino:test/mock';

await mockFetch(async (mock) => {
  mock.get('https://api.example/me')
    .header('authorization', /^Bearer /)
    .once()
    .reply(200, '{"id":1}');
  await fetch('https://api.example/me', {
    headers: { authorization: 'Bearer token' },
  });
});

Methods

header(name: string, matcher: HeaderMatcher): this

Require a request header to match before the response is used.

Header names are normalized to lowercase. Matchers may be exact strings, regular expressions, or functions that inspect the header value and call.

mock.get('https://api.example/me')
  .header('authorization', /^Bearer /)
  .reply(200);
body(matcher: BodyMatcher): this

Require the request body to match before the response is used.

String and RegExp matchers use the UTF-8 decoded body. Uint8Array matches raw bytes, and function matchers receive both forms plus the full call.

mock.post('https://api.example/items')
  .body(/\"name\":\"Ada\"/)
  .reply(201);
times(count: number): this

Expect this request pattern count times.

The count must be a positive integer. The expectation remains at the front of the queue until all calls have matched.

mock.get('https://api.example/ping').times(3).reply(204);
once(): this

Expect this request pattern exactly once.

mock.get('https://api.example/ping').once().reply(204);
twice(): this

Expect this request pattern exactly twice.

mock.get('https://api.example/ping').twice().reply(204);
reply(status = 200, body?: unknown, init: { headers?: unknown; statusText?: string; } = {}): this

Respond with a new Response using the provided status, body, and init.

This is the simple static-response path. Use replyWith() when the response needs to inspect the matched request.

mock.get('https://api.example/me')
  .reply(200, JSON.stringify({ id: 1 }), {
    headers: { 'content-type': 'application/json' },
  });
replyWith(response: MockResponseFactory): this

Respond with a Response or response factory.

Factories may be async and receive the captured call, which makes this useful for echo responses or request-dependent status codes.

mock.post('https://api.example/echo')
  .replyWith((call) => new Response(call.text, { status: 200 }));
passthrough(): this

Forward the matched call to the original fetch implementation.

This is useful when one call in a scoped mock should use a real or test-installed fetch while the rest of the scope remains mocked.

mock.get('https://api.example/live').passthrough();
networkError(message = 'mock fetch network error'): this

Reject the matched call with a forced network error.

The rejection is a TypeError, matching the shape commonly used by fetch implementations for network failures.

mock.get('https://api.example/down').networkError('socket hang up');
abort(): this

Reject the matched call with an AbortError.

mock.get('https://api.example/slow').abort();

class MockFetchScope {

Scoped fetch mock that records calls and verifies queued expectations.

A scope does not replace globalThis.fetch until run() is called. Each expected request is matched in order, and verify() fails if any expected calls remain.

import { MockFetchScope } from 'fino:test/mock';

const scope = new MockFetchScope('https://api.example');
scope.get('/health').reply(200, 'ok');
await scope.run(async () => {
  await fetch('https://api.example/health');
});

Constructors

constructor(baseUrl: string | URL | null = null)

Create a mock scope with an optional base URL for relative expectations.

When baseUrl is provided, expectation URLs such as /v1/items are resolved against it. Actual fetch() calls still use normal absolute URLs.

import { MockFetchScope } from 'fino:test/mock';

const mock = new MockFetchScope('https://api.example');
mock.get('/v1/items').reply(200);

Getters

get calls(): readonly MockFetchCall[]

Captured calls made while the scope was active.

The array is read-only to callers but updates as requests are dispatched. It includes calls that failed expectation matching.

await mockFetch(async (mock) => {
  mock.get('https://api.example').reply(200);
  await fetch('https://api.example');
  mock.calls[0]?.method; // 'GET'
});

Methods

get(input: string | URL): MockFetchExpectation

Register an expected GET request.

mock.get('https://api.example/items').reply(200, '[]');
post(input: string | URL): MockFetchExpectation

Register an expected POST request.

mock.post('https://api.example/items').body('{"name":"Ada"}').reply(201);
put(input: string | URL): MockFetchExpectation

Register an expected PUT request.

mock.put('https://api.example/items/1').reply(200);
patch(input: string | URL): MockFetchExpectation

Register an expected PATCH request.

mock.patch('https://api.example/items/1').reply(200);
delete(input: string | URL): MockFetchExpectation

Register an expected DELETE request.

mock.delete('https://api.example/items/1').reply(204);
head(input: string | URL): MockFetchExpectation

Register an expected HEAD request.

mock.head('https://api.example/items/1').reply(200);
options(input: string | URL): MockFetchExpectation

Register an expected OPTIONS request.

mock.options('https://api.example/items').reply(204);
verify(): void

Verify that all registered expectations were consumed.

run() calls this automatically after the callback. Call it manually only when dispatching through lower-level scope plumbing.

const scope = new MockFetchScope();
scope.verify(); // passes when no calls remain
async run<T>(fn: (mock: MockFetchScope) => T | Promise<T>): Promise<T>

Replace global fetch while fn runs and verify expectations afterwards.

The original fetch function is restored in finally, even if the callback throws or verification fails. The callback's return value is returned.

const scope = new MockFetchScope();
scope.get('https://api.example').reply(200);
await scope.run(async () => {
  await fetch('https://api.example');
});

Functions

async function mockFetch<T>( baseUrlOrFn: string | URL | ( ( mock: MockFetchScope ) => T | Promise<T> ), maybeFn?: ( mock: MockFetchScope ) => T | Promise<T> ): Promise<T>

Temporarily replace global fetch while fn runs and verify expectations afterwards.

Pass a callback directly for absolute URLs, or pass a base URL first to make expectation URLs relative. The original fetch is always restored.

import { mockFetch } from 'fino:test/mock';

await mockFetch('https://api.example', async (mock) => {
  mock.get('/health').reply(200, 'ok');
  await fetch('https://api.example/health');
});