Skip to content

Service virtualization

Use tablewalk/testing to run a local HTTP service with static or asynchronous replies. Each instance owns its state, request journal, logical clock and seeded randomness. Inject its URL into your App’s server configuration.

import { defineService, startService } from 'tablewalk/testing';
const credit = defineService({
name: 'credit-check',
state: { checks: {} as Record<string, { score: number }> },
routes: [
{
id: 'create',
match: { method: 'POST', path: '/checks', body: { partial: { name: 'Ada' } } },
reply: ({ state, random }) => {
const id = String(Object.keys(state.checks).length + 1);
state.checks[id] = { score: Math.floor(random() * 1000) };
return { status: 201, body: { id, ...state.checks[id] } };
},
},
{
id: 'read',
match: { method: 'GET', path: '/checks/:id' },
reply: ({ state, params }) => Object.hasOwn(state.checks, params.id)
? { body: state.checks[params.id] }
: { status: 404, body: { error: 'Unknown check' } },
},
],
scenarios: {
unavailable: [{
id: 'create',
match: { method: 'POST', path: '/checks' },
reply: { status: 503, body: { error: 'Try later' } },
}],
},
});
await using service = await startService(credit, { seed: 42 });
const response = await fetch(`${service.url}/checks`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'Ada' }),
});
const result = await response.json();
service.verify({ method: 'POST', path: '/checks' }, { times: 1 });
service.verify({ method: 'DELETE' }, { times: 0 });

await using stops the listener and verifies the service at scope exit. With a runner that does not support explicit resource management, register service.close() in its teardown hook immediately after startup. Unexpected requests, malformed JSON, ambiguous routes and handler errors fail teardown, even if application code catches the resulting HTTP error. Missing expected calls fail when verify runs. Intentional HTTP errors and transport faults are expected behavior and remain visible in service.journal().

Omitted matcher fields impose no constraint. Methods are case-insensitive. Paths match literal segments, named :parameters, or a final * remainder. Parameters are percent-decoded after splitting the path: an encoded slash stays inside its parameter. Duplicate parameter names and non-final wildcards are invalid. Empty parameter values do not match.

Query and header matchers accept a string, an ordered string array, a regular expression or a synchronous predicate over the received values. A string or regular expression requires exactly one value. An empty array asserts absence. Header names are case-insensitive; query names are case-sensitive. Additional unmentioned headers and query keys are allowed. Regular expressions retain no cursor state across requests.

Bodies are parsed as JSON for JSON content types, as fields for URL-encoded forms, and as text otherwise. Use { exact: value }, { partial: value } or { predicate: body => boolean }. Partial matching recurses through object properties; arrays retain exact order and cardinality. Predicates execute trusted synchronous test code. They must return a boolean.

If several routes match, the highest numeric priority wins. Ties fail with the conflicting route IDs. Declaration order never silently resolves an ambiguous match. An unmatched request reports differences for candidate routes without including body or header values.

Handlers receive params, query, headers, parsed body, original UTF-8 rawBody for webhook signature verification, state, clock, random and an abort signal. Handlers execute serially within an instance. Each sees a private copy of the last committed state. Successful completion commits that copy before the transport reply; an exception or handler timeout rolls it back. Concurrent requests cannot lose a state update. Retaining a state reference cannot mutate a later transaction. A transport disconnect may happen after state commits, which supports testing uncertain outcomes and idempotency.

Handlers are trusted code. Await their asynchronous work and pass signal to outbound calls. The engine cannot cancel arbitrary detached work or undo an external side effect performed by a handler.

service.scenario('unavailable') selects named route overrides. Same-ID routes replace base routes; other IDs add routes. Already admitted requests keep their captured scenario. service.scenario(null) returns to base behavior. Scenario changes retain state and journal. service.reset() requires an idle, verified instance and restores initial state, base routes, empty journal, clock and seed. It cannot erase a failed test.

Logical time starts at now (default 0) and advances only through service.clock.advance(milliseconds). A handler can await clock.sleep(...); the test advances time to release it. Seeded random() produces the same sequence after reset. Use state counters for repeatable sequences such as two 503 replies followed by success.

A reply can set delayMs for a wall-clock response delay, fault: 'disconnect' to close the connection, or fault: 'timeout' to withhold a response until the caller cancels or the request deadline expires. These are separate from logical business time. The default total request deadline is 5 seconds; configure handlerTimeoutMs up to 300 seconds when needed. A caller’s cancellation fences queued handlers and late state commits.

The default body limit is 1 MiB and journal limit is 10,000 requests. Configure maxBodyBytes and maxJournalEntries for a bounded larger fixture. Exceeding a limit fails the test. Responses own their framing; do not supply content-length, transfer-encoding or connection headers.

Default-export your defineService(...) definition from a local TypeScript module, then run:

Terminal window
tablewalk virtual credit-service.ts --port 0 --seed 42 --now 0
tablewalk virtual credit-service.ts --port 4700 --scenario unavailable

The command prints its loopback URL. Port 0 lets the OS choose a free port. SIGINT or SIGTERM closes the service and exits unsuccessfully if verification found unexpected requests or handler failures. The module executes as trusted local code. The embedded API additionally provides logical-clock advancement, journal inspection and per-test verification.

Keep a real provider contract check alongside simulated integration tests: a simulation proves how your App handles its declared protocol, but cannot establish that an external provider still implements that protocol.

The repository’s examples/testing/credit-service.ts models pending checks, controlled completion and callbacks. Advance its clock by 1,000 milliseconds, then POST /dispatch to deliver due callbacks. Each callback signs timestamp + '.' + rawBody with HMAC-SHA256 using an explicitly supplied test secret. The receiver verifies the raw-body signature and timestamp window and deduplicates the signed check ID. Dispatch awaits the callback and marks it delivered only after a successful response; receivers must tolerate a retry after an uncertain transport outcome. The unavailable, slow, disconnected and retry scenarios exercise failure handling without contacting a provider.