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');
});