Test your application locally
Prompt:
Make the tests run without the cloud: fast, offline, and the same result every time.
Also works:
- "Test the records layer without touching the development database."
- "The suite hangs when I have no connection; fix that."
This page adds offline tests to an application you already have. A new service starts with the scaffold line in Starting a new service, which writes a test script and a first test file, and runs the tests. A service that keeps its data in the database starts with the one line in Build a database-backed service, which also writes the test engine as a development dependency. The hello-world and scheduled-job templates test with node --test and declare no test engine. For such a service, read this page for what each double does and how to write further tests.
What your tool does
- Checks which backend packages the application has under
app/lib/. It imports each package's test double from@turnzero/<name>/testing, beside the client it imports from@turnzero/<name>. - Adds
@electric-sql/pgliteto the development dependencies, at the version the Database package's contract gives, and runsnpm install. The other doubles install nothing. - Turns a module that reads a setting when it is imported, such as a data layer that opens its pool from
APP_DATABASE_URL, into a factory that takes the pool as an argument. - Writes each test file to construct its doubles once, call
reset()between cases, and close them at the end. - Asserts on what the application returns and on what the double recorded. The platform's own guarantees, such as the connection limit, Transport Layer Security (TLS), and credential rotation, are tested in a local run or against a deployed environment instead. A local run is your application running on your own machine against the development records every application keeps: its development database, platform credential, secret scope, storage partitions, and
locallog stream. - Runs the suite with
npm testand reports the count and the time. - Asks you nothing: every write is to your project, and no platform action is called.
What you need
- Node.js, npm, and a test runner installed on your computer. The examples use Vitest. Node's built-in runner works too: run
node --testalone, which finds the test files itself. To name them, give a quoted pattern such asnode --test "test/**/*.test.mjs", not a folder such astest/. In Windows PowerShell, run npm as What your computer needs describes.
Before your AI starts
This section is for your AI tool: what it checks and gathers before it begins. You don't need to do these steps yourself.
- The Database package under
app/lib/database/, declared asfile:lib/database, at a version whose entry includes thetestingsubpath (Use the library). - The schema kept as ordered
.sqlfiles undermigrations/, and the data layer written over a pool with thepg.Poolshape (Add a database). - Any of the Storage, Logging, Egress, Schedule, AI Allowance, and Account packages the application uses, each at a version that includes the
testingsubpath. The module that uses each client takes the client as an argument. - A
testentry underscriptsinpackage.jsonthat runs the runner, such asvitest run, sonpm testruns the suite. The samples are TypeScript files undertests/that test plain-JavaScript modules undersrc/, with no build step. - No account, no connection, and no environment file. A double reaches nothing outside the test's process. Where an application runs during development compares a test over the doubles with a local run and a deployed copy.
Steps
1. Install the engine
The database double runs your migrations and queries on PGlite, a PostgreSQL build that runs inside the Node.js process. Your test constructs the engine and passes it to the double, so install it as a development dependency, and it never reaches the deployed image. Use the version the package's contract names in its Verification section: 0.5.8 at this version of the package. No test checks this sample.
npm install --save-dev @electric-sql/pglite@0.5.8
2. Construct the double
createDatabaseDouble({ engine, files }) from @turnzero/database/testing takes the engine and your migration files. It checks the engine with one probe statement, applies the files with the package's own migration function, and returns { pool, applied, statements, reset, close }. migrationsFrom(dir) comes with it and reads a folder of migration files from a path the process resolves.
The files are applied the same way as when the application starts, each in its own transaction, so a broken migration fails the construction with the engine's error before any case runs. Construct the double once per test file, because the engine takes about a second to start.
pool has the shape your application already uses: query(text, values?), connect(), and end(). It handles one caller at a time, so two transactions never interleave. A bigint such as count(*) comes back as a string, as it does from the pg driver, so write count(*)::int where your code needs a number. A constraint violation includes the server's own code, such as 23514 for a check and 23503 for a foreign key.
3. Hand the pool to your application
A module that opens its pool from APP_DATABASE_URL when it is imported cannot be tested without the setting, and a test that sets it reaches the development database. Make the data layer a factory over a pool, createDb(pool), and put the one function that builds the live pool, openLiveDb(), beside it. Only the process entry calls openLiveDb(). The worked example's data module follows, shortened to the functions the server below and step 4 use. No test checks this sample.
// src/db.mjs: the data layer over any pool with the pg.Pool shape, and the one function that builds the live pool.
import pg from 'pg';
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { bootWalk, createFailoverPool } from '@turnzero/database';
const MIGRATIONS = join(dirname(fileURLToPath(import.meta.url)), '..', 'migrations');
export function createDb(pool) {
const q = (text, values) => pool.query(text, values);
return {
async knownCount() {
const { rows } = await q('SELECT count(*)::int AS n FROM animals');
return rows[0].n;
},
async upsertRecord({ name, kind, base_name = null }) {
await q(
`INSERT INTO animals (name, kind, base_name) VALUES ($1, $2, $3)
ON CONFLICT (name) DO UPDATE SET kind = EXCLUDED.kind, base_name = EXCLUDED.base_name, written = now()`,
[name, kind, base_name],
);
},
async applyMigrations() {
// The package's boot walk: one connection for the walk, a lost connection
// retried every second for up to 120 seconds, any other failure at once.
return bootWalk(pool, {
list: () => readdirSync(MIGRATIONS),
read: (name) => readFileSync(join(MIGRATIONS, name), 'utf8'),
});
},
async close() {
await pool.end();
},
};
}
export function openLiveDb() {
const url = process.env.APP_DATABASE_URL;
if (!url) throw new Error('APP_DATABASE_URL is not set: set it before starting the process; the tests need no setting and run over the test double.');
// The plan's connection_limit, which the platform injects beside the URL and the
// local run's setup writes into .env; one connection where the setting is absent.
const limit = process.env.APP_DATABASE_CONNECTION_LIMIT ?? '';
const max = /^[1-9][0-9]*$/.test(limit) ? Number(limit) : 1;
// The Database package's failover pool bounds each connect and listens for the
// sessions a failover ends, so the process keeps serving.
const pool = createFailoverPool({
connect: (settings) => new pg.Pool({ connectionString: url, ...settings }),
max,
onConnectionLoss: (error) => console.warn('database session ended:', error.message),
});
return createDb(pool);
}
The server builds its handlers over the layer it is given, makeServer({ db }). Its entry block runs only when the module is the process's main script, and a test imports the module without running it. No test checks this sample.
// src/server.mjs: the server over the layer it is given; the process entry alone builds the live one.
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
import { openLiveDb } from './db.mjs';
export function makeHandlers(db) {
return {
'GET /health': async () => {
await db.knownCount();
return { ok: true };
},
'GET /api/state': async () => ({ known: await db.knownCount() }),
};
}
export function makeServer({ db }) {
const handlers = makeHandlers(db);
return createServer(async (req, res) => {
const handler = handlers[`${req.method} ${new URL(req.url, 'http://localhost').pathname}`];
if (!handler) {
res.writeHead(404).end();
return;
}
try {
const body = JSON.stringify(await handler());
res.writeHead(200, { 'content-type': 'application/json' }).end(body);
} catch {
res.writeHead(500).end();
}
});
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const port = Number(process.env.PORT ?? 8630);
const db = openLiveDb();
await db.applyMigrations();
makeServer({ db }).listen(port);
}
4. Write the test file
Construct the double in beforeAll, and pass createDb(double.pool) to the code under test. Call reset() in beforeEach: it empties every table except the migration ledger, so each case starts with the schema alone. Call close() in afterAll.
Set hookTimeout in your runner configuration, for example test: { hookTimeout: 600_000 } in vitest.config. Vitest's default is ten seconds, and the engine can take much longer than its usual second to start on a busy machine. The worked example sets hookTimeout to ten minutes.
The samples in this step and step 7 are parts of the test files of two worked examples, a records game named animal and a scheduled application named chime. A test checks that each one matches the example's test file byte for byte. The records layer's opening lines follow.
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PGlite } from '@electric-sql/pglite';
import { createDatabaseDouble, migrationsFrom } from '@turnzero/database/testing';
// @ts-ignore
import { createDb } from '../src/db.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const MIGRATIONS = join(here, '..', 'migrations');
let double: Awaited<ReturnType<typeof createDatabaseDouble>>;
let db: any;
beforeAll(async () => {
double = await createDatabaseDouble({ engine: new PGlite(), files: migrationsFrom(MIGRATIONS) });
db = createDb(double.pool);
});
beforeEach(() => double.reset());
afterAll(() => double.close());
The body of one case follows: an insert through upsertRecord, an update through the conflict path, and the schema's own check rejected by the engine. stampImage and record are two more of the example's functions.
await db.upsertRecord({ name: 'bear', kind: 'base' });
await db.stampImage('bear', 'bear.png');
await db.upsertRecord({ name: 'bear', kind: 'singular' }); // the conflict path
expect(await db.record('bear')).toEqual({ name: 'bear', kind: 'singular', base_name: null, image: 'bear.png' });
expect(await db.knownCount()).toBe(1); // updated, never duplicated
// The three-value kind check is the schema's own, refused by the engine
// under its SQLSTATE and its constraint name (23514, check_violation).
await expect(db.upsertRecord({ name: 'x', kind: 'common' })).rejects.toMatchObject({ code: '23514' });
await expect(db.upsertRecord({ name: 'x', kind: 'common' })).rejects.toThrowError(/animals_kind_check/);
5. Read what the double recorded
appliedlists the migration files the construction applied, in order.statementsrecords every statement the pool ran, including the migrations' statements. Each entry is the statement's text, then--and the JSON of its values where it had any.reset()leaves the ledger table as it was, so running the application's own migration function again applies nothing.
6. Know what the database double does not reproduce
The double reproduces PostgreSQL's responses and the migration walk, not the platform around the database. Test the following in a local run against the development database, as Run locally describes, or against a deployed environment:
- A second session. The engine has one session, so nothing competes with the test's.
- A client held too long. A
pool.queryissued while a client is checked out waits, and past the double's limit it fails withsession_held. - The connection string, TLS, and
sslmode. The setting is never read. - The role's connection limit, and the overlap during a deploy or a failover. The pool's maximum,
APP_DATABASE_CONNECTION_LIMIT, is read only by the function that builds the live pool, which the tests never call. - Provisioning, backups, metering, and credential rotation.
- The managed server's version and time zone. A timestamp the server turns into text can differ.
7. Use the other doubles
Each of the other doubles takes the place of a platform service behind the package's real client, and responds to every call in memory. Construct the client over double.transport. Where your application's own transport wraps the global fetch, keep it and stub the global with double.fetch. Each double records every call for your assertions.
Each package's contract and integration guide describe its double in full, with every option, recorded member, and scripting call, and a sample in the guide's Test setup section. Both are files in the package's library entry, which has no web address, so your tool reads them with read_library_entry, giving the package's name and the file.
| Package | Factory | How the client connects | Set-up | Option a local-run case needs | Contract and integration guide |
|---|---|---|---|---|---|
| Database | createDatabaseDouble({ engine, files }) |
Pass double.pool to the data layer. |
Once per file, as step 4 shows. | None. | features/database/database.md, features/database/integration.md |
| Storage | createStorageDouble(options) |
new StorageWireClient(double.transport), or stub fetch. |
Once per file. reset() forgets every area, so declare areas in each case. |
None. | features/storage/storage.md, features/storage/integration.md |
| Logging | createLoggingDouble(options) |
new LogClient(double.transport, { environment: 'local' }), or stub fetch. |
Call await client.flush() before reading the double, and await client.close() at the end. |
environment: 'local'. |
features/logging/logging.md, features/logging/integration.md |
| Egress | createEgressDouble(options) |
EgressClient over double.transport, or stub fetch. |
Once per file. Declare each upstream with double.declare(name, handler, { application }), giving the application it is bound to. |
credential: 'session', where the transport sends the environment header. |
features/egress/egress.md, features/egress/integration.md |
| Schedule | createScheduleDouble(router, { now }) |
Wrap the router createScheduleRouter builds from manifest.json and your handlers, then fire(schedule, options). |
Once per server. reset() empties double.fires. |
None. | features/schedule/schedule.md, features/schedule/integration.md |
| AI Allowance | createAiAllowanceDouble(options) |
Pass double.transport to generateText, or stub fetch. |
Set quota and weights, and queue each answer with double.prime(...). |
None. | features/ai_allowance/ai_allowance.md, features/ai_allowance/integration.md |
| Account | await createAccountDouble(options) |
double.client(), or the real client over the double's functions. |
Once per file in beforeAll. Sign users in with signIn(user). |
None. | features/account/account.md, features/account/integration.md |
| Push | createPushDouble(options) |
createPushClient({ transport: double.transport }), or pass double.fetch as the client's fetch. |
Once per file. Register devices with double.register(device), and read double.sends. |
None. | features/push/push.md, features/push/integration.md |
| Issue Tracking | createIssueTrackingDouble(options) |
createIssueTrackingClient(view.transport) with no options, where view is double.gatewayView({ space }) over the space.id that double.createSpace() returns: the gateway's view, as the deployed backend reaches its space. |
Once per file. reset() empties every space, so seed a space and take its view in each case. The view takes no quantity and no stored-data state at 0.3.0. |
None. | features/issue_tracking/issue_tracking.md, features/issue_tracking/integration.md |
The storage double
The animal example runs its area-draining script over the double. The file's opening lines build the real client over the double's transport, and a helper declares and fills the area.
import { beforeEach, describe, it, expect } from 'vitest';
import { StorageWireClient } from '@turnzero/storage';
import { createStorageDouble } from '@turnzero/storage/testing';
// @ts-ignore
import { drainArea } from '../scripts/delete_records_area.mjs';
// @ts-ignore
import { IDENTITY } from '../src/model.mjs';
const AREA = 'animal-records';
const storage = createStorageDouble();
const client = new StorageWireClient(storage.transport);
beforeEach(() => storage.reset());
/** The area declared and filled, one record file per name; answers the call
* index the drain's own calls start at. */
async function filled(files: string[]) {
await client.mintArea(AREA, { accountKeyed: false, versionKeeping: false });
for (const name of files) await client.put(AREA, name, new TextEncoder().encode('{}'), { identity: IDENTITY, contentType: 'application/json' });
return storage.calls.length;
}
The body of the script's first case follows, reading the DELETE calls from the double's calls.
const from = await filled(['animals/cheetah.json', 'animals/polar bear.json', 'index.json']);
const deleted = await drainArea(storage.transport, { area: AREA });
expect(deleted).toBe(3);
expect(storage.files.get(AREA)!.size).toBe(0);
const calls = storage.calls.slice(from);
const deletes = calls.filter((c) => c.method === 'DELETE');
expect(deletes.length).toBe(3);
for (const d of deletes) {
expect(d.path.startsWith('/storage/v0/areas/animal-records/files/')).toBe(true);
expect(d.headers['x-acting-identity']).toBe(IDENTITY);
}
// The drain ends on a re-list that answers empty, never on a guess.
expect(calls.at(-1)).toMatchObject({ method: 'GET', path: '/storage/v0/areas/animal-records/files?limit=1' });
Where the application's own transport wraps the global fetch, the case stubs the global with the double's fetch and imports the module afterwards. Afterwards it puts back the sign-in file the suite's configuration names, so no later case reads your own. The body of the conditional-write case follows.
process.env.ANIMAL_AUTH_FILE = authFileFor();
vi.stubGlobal('fetch', storage.fetch);
try {
// @ts-ignore
const { CloudStore } = await vi.importActual<any>('../src/cloudstore.mjs');
const c = new CloudStore();
const buf = Buffer.from('x');
// The arrangement runs through the store under test: the area minted,
// one file written, its version the one the double issued.
await c.mintArea('a', { accountKeyed: false, versionKeeping: false });
const { version } = await c.put('a', 'n', buf, { identity: 'i' });
await expect(c.put('a', 'n', buf, { identity: 'i', mustNotExist: true })).rejects.toMatchObject({ code: 'exists', version });
await expect(c.put('a', 'n', buf, { identity: 'i', ifVersion: '"v1"' })).rejects.toMatchObject({ code: 'version_moved', version });
// A named-version put of an absent name is the wire's absent-name refusal (404), which the client reads as version_moved with no current version.
await expect(c.put('a', 'absent', buf, { identity: 'i', ifVersion: version })).rejects.toMatchObject({ code: 'version_moved', version: null });
await expect(c.put('b', 'n', buf, { identity: 'i' })).rejects.toMatchObject({ code: 'no_area' });
await expect(c.put('a', 'n', buf, {})).rejects.toMatchObject({ code: 'no_identity' });
} finally {
vi.unstubAllGlobals();
restoreSignIn();
}
The logging double
One case uses two doubles. The first refuses the next batch with a bare 503, so the client's first flush goes to the fallback. The second fails, as an unreachable service does. The body follows, up to the point where both clients close.
const { LogClient, DROPPED_COUNTER } = await import('@turnzero/logging');
expect(DROPPED_COUNTER).toBe('logging.dropped');
// The seam is the subject, so the ingest is the Logging package's test
// double behind the real client, under the environment a local run
// writes (`local`, admitted by the double's default posture). A refusing
// ingest, scripted ahead of the double's own validation: the batch is
// written to the fallback, one line per record behind a summary naming
// the refusal, and counted as dropped.
const { createLoggingDouble } = await import('@turnzero/logging/testing');
const lines: string[] = [];
const double = createLoggingDouble();
double.refuseNext(503);
const client = new LogClient(double.transport, { environment: 'local', fallback: (line: string) => lines.push(line) });
expect(client.log.info('animal listening', { port: 1 })).toBeUndefined(); // synchronous: no promise answers
expect(client.count('requests')).toBeUndefined();
await expect(client.flush()).resolves.toBeUndefined(); // never rejects
expect(lines[0]).toMatch(/2 record\(s\) not delivered to the logging service \(refused 503\); written here instead$/);
expect(lines.some((l) => l.includes('info [local] animal listening {"port":1}'))).toBe(true);
expect(lines.some((l) => l.includes('counter [local] requests +1'))).toBe(true);
expect(client.stats().dropped).toBe(2);
// The losses ride the next delivered batch as the drop counter's
// increment; that batch reaches the double's own ingest, which answers
// the service envelope and folds the counter.
client.count('requests');
await client.flush();
expect(double.batches.at(-1)).toMatchObject({ path: '/logging/v0/batch', method: 'POST', status: 200 });
expect(double.batches.at(-1)?.body?.counters).toEqual(expect.arrayContaining([{ name: DROPPED_COUNTER, increment: 2 }]));
expect(double.total('requests')).toBe(1);
expect(client.stats().delivered).toBe(1);
// An unreachable ingest — the transport throws — is the same fallback, no
// throw. A second double carries the scripted failure, so the first
// client's timer can never consume it.
const unreachable: string[] = [];
const down = createLoggingDouble();
down.failNext(new Error('ECONNREFUSED'));
const thrower = new LogClient(down.transport, { environment: 'local', fallback: (line: string) => unreachable.push(line) });
thrower.log.error('request failed', { path: '/api/state' });
await expect(thrower.flush()).resolves.toBeUndefined();
expect(unreachable[0]).toContain('(ECONNREFUSED)');
expect(thrower.stats().dropped).toBe(1);
await client.close();
await thrower.close();
The egress double
The example's transport wraps the global fetch and sends the environment header on every call, so its suite constructs the double with credential: 'session'. The stub keeps each URL the transport built. The suite's opening lines follow.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createEgressDouble } from '@turnzero/egress/testing';
const here = dirname(fileURLToPath(import.meta.url));
const src = (f: string) => readFileSync(join(here, '..', 'src', f), 'utf8');
const CONTROL = 'https://control.example';
const GATEWAY = 'https://gateways.example';
const SETTINGS = ['TURNZERO_CLOUD_API', 'TURNZERO_CLOUD_GATEWAY_URL', 'TURNZERO_CLOUD_TOKEN', 'ANIMAL_AUTH_FILE', 'ANIMAL_ASK_TIMEOUT_MS'] as const;
const held: Record<string, string | undefined> = {};
// One gateway for the file, standing under the developer's own sign-in — the
// credential a local run holds, under which the transport names the
// environment on every call (`x-turnzero-cloud-environment: development`),
// which the double's default platform-credential posture would refuse as a
// mismatch. The calls empty between cases; the declarations are each case's
// own. The stub wraps `double.fetch` to keep the composed URL, which is the
// transport's (originFor) and which the double's recorded path alone would
// lose.
const double = createEgressDouble({ credential: 'session' });
const urls: string[] = [];
const bodyText = (body: Uint8Array | string | undefined) => (typeof body === 'string' ? body : new TextDecoder().decode(body ?? new Uint8Array()));
beforeEach(() => {
for (const name of SETTINGS) {
held[name] = process.env[name];
delete process.env[name];
}
const dir = mkdtempSync(join(tmpdir(), 'animal-egress-'));
const file = join(dir, 'tokens.json');
writeFileSync(file, JSON.stringify({ api: CONTROL, client_id: 'c1', access_token: 'a1', refresh_token: 'r1' }));
process.env.ANIMAL_AUTH_FILE = file;
process.env.TURNZERO_CLOUD_API = CONTROL;
process.env.TURNZERO_CLOUD_GATEWAY_URL = GATEWAY;
double.reset();
urls.length = 0;
vi.stubGlobal('fetch', (url: string | URL, init?: RequestInit) => {
urls.push(String(url));
return double.fetch(String(url), init as any);
});
});
afterEach(() => {
vi.unstubAllGlobals();
for (const name of SETTINGS) {
if (held[name] === undefined) delete process.env[name];
else process.env[name] = held[name];
}
});
// The modules under test are untyped JavaScript; importActual keeps another
// file's module-scope mocks of gemini.mjs and claude.mjs away from this one.
// @ts-ignore
const claude = () => vi.importActual<any>('../src/claude.mjs');
// @ts-ignore
const gemini = () => vi.importActual<any>('../src/gemini.mjs');
// @ts-ignore
const upstream = () => vi.importActual<any>('../src/upstream.mjs');
Another case gets a 429 from a declared upstream itself, and the double's refusal of an undeclared upstream. Its body follows.
// The declared upstream answers its own 429, which passes through marked;
// gemini-image stays undeclared, so the double answers the gateway's own
// unmarked refusal.
double.declare('gemini-text', () => ({ status: 429, json: { error: { status: 'RESOURCE_EXHAUSTED' } } }), { application: 'animal' });
const { askImage, askStructured } = await gemini();
const { GatewayRefusal, UpstreamError } = await upstream();
// The quota class rides the marked answer: the animal's own typing.
const quota = await askStructured('q', { type: 'object' }).catch((e: unknown) => e);
expect(quota).toBeInstanceOf(UpstreamError);
expect(quota).toMatchObject({ quota: true, status: 429, upstream: 'gemini-text' });
// The gateway's own answer rides no marker: the package's typing, no retry.
const refused = await askImage('a cat').catch((e: unknown) => e);
expect(refused).toBeInstanceOf(GatewayRefusal);
expect(refused).toMatchObject({ error: 'undeclared_upstream', status: 404, kind: 'declaration', upstream: 'gemini-image' });
expect((refused as { quota?: boolean }).quota).toBeUndefined();
expect(double.calls.map((c) => c.outcome)).toEqual(['passed', 'undeclared_upstream']);
The body of the streaming case follows, with the handler responding in chunks under content-type: text/event-stream.
const frame = (text: string, extra: Record<string, unknown> = {}) =>
`data: ${JSON.stringify({ candidates: [{ content: { parts: [{ text }] } }], ...extra })}\n\n`;
const raw = frame('{"question": "Does it ') + frame('fly?", "yes": ["hawk"], ') + frame('"no": ["cat"]}', { usageMetadata: { promptTokenCount: 2, candidatesTokenCount: 5 } });
// Under the event-stream content type the double forwards the chunks one by one.
double.declare('gemini-text', () => ({ headers: { 'content-type': 'text/event-stream' }, chunks: [raw.slice(0, 20), raw.slice(20)] }), { application: 'animal' });
const { askStructuredStream } = await gemini();
const chunks: string[] = [];
const out = await askStructuredStream('p', { type: 'object' }, { onChunk: (t: string) => chunks.push(t) });
expect(out).toEqual({ value: { question: 'Does it fly?', yes: ['hawk'], no: ['cat'] }, tokens: 7 });
expect(chunks.join('')).toBe('Does it fly?');
expect(urls[0]).toBe(`${GATEWAY}/egress/v0/gemini-text/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse`);
expect(double.calls[0]).toMatchObject({ upstream: 'gemini-text', outcome: 'passed', status: 200 });
The schedule double
The chime example's server exposes its router as server.router, and its suite wraps that router in the double on the same clock. The suite's opening lines follow, with the start helper every case calls.
import { execFileSync, spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { existsSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createScheduleDouble, type FireResult } from '@turnzero/schedule/testing';
// @ts-ignore The server is plain JavaScript.
import { makeServer, readManifest } from '../src/server.mjs';
// @ts-ignore The ring is plain JavaScript.
import { RunRing } from '../src/runs.mjs';
const here = dirname(fileURLToPath(import.meta.url));
const appRoot = dirname(here);
const projectRoot = dirname(appRoot);
type Server = ReturnType<typeof makeServer>;
/** The parsed JSON body of an answered fire: the envelope's `json` member,
* present where the router answered with a JSON content type. */
const bodyOf = (fired: FireResult): Record<string, unknown> => {
if (fired.outcome !== 'answered') throw new Error(`the fire ended as ${fired.outcome}`);
return fired.json as Record<string, unknown>;
};
/** A server on an ephemeral port with a scripted clock and a record sink,
* and the package's double over its router on the same clock, so the due
* header and the ring's received instant read one clock. */
async function start(o: { now?: () => Date } = {}) {
const records: unknown[] = [];
const server: Server = makeServer({ record: (r: unknown) => records.push(r), ...(o.now ? { now: o.now } : {}) });
const double = createScheduleDouble(server.router, o.now ? { now: o.now } : {});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address() as { port: number };
const base = `http://127.0.0.1:${address.port}`;
return { server, double, base, records, close: () => new Promise<void>((resolve) => server.close(() => resolve())) };
}
The body of the first run case follows.
const s = await start({ now: () => new Date('2026-09-12T16:15:00.400Z') });
try {
const fired = await s.double.fire('chime', { due: '2026-09-12T16:15:00.000Z', run: 'run_a' });
expect(fired).toMatchObject({ outcome: 'answered', status: 200 });
expect(bodyOf(fired)).toEqual({ ok: true, schedule: 'chime', run: 'run_a', due: '2026-09-12T16:15:00.000Z', repeat: false, held: 1 });
expect(s.double.fires[0]).toMatchObject({ schedule: 'chime', run: 'run_a', due: '2026-09-12T16:15:00.000Z', path: '/schedules/chime', outcome: 'answered', status: 200 });
expect(s.server.ring.entries()).toEqual([
{ schedule: 'chime', run: 'run_a', due: '2026-09-12T16:15:00.000Z', key: 'chime@2026-09-12T16:15:00.000Z', receivedAt: '2026-09-12T16:15:00.400Z', repeat: false },
]);
expect(s.records).toEqual([
{ event: 'scheduled_run', schedule: 'chime', run: 'run_a', due: '2026-09-12T16:15:00.000Z', path: '/schedules/chime', status: 200, duration_ms: 0, ts: '2026-09-12T16:15:00.400Z' },
]);
} finally {
await s.close();
}
A case calls request with a schedule header for the other declared schedule. The router refuses the request at the path. Its body follows.
const s = await start();
try {
const res = await s.double.request('/schedules/chime', s.double.headersFor('sweep', '2026-09-12T16:45:00.000Z'));
expect(res.handled).toBe(true);
expect(res.statusCode).toBe(404);
expect(JSON.parse(res.text).error).toBe('scheduled_handler_only');
expect(s.server.ring.size).toBe(0);
} finally {
await s.close();
}
The account double and a native app's bearer
A native app sends the same session token that a browser's cookie contains, as Authorization: Bearer <token>. So a backend test of a native request needs nothing new. Pass the session that signIn(user) returns where your backend reads the bearer token, and verify it with verifySession as you verify a cookie's. For a test under Node's built-in runner, the Account package's integration guide, which read_library_entry returns, has a sample that runs with node --test.
The double provides no token endpoint, no refresh, and no session list. Test the app's sign-in, its refresh, and its session list against the application's own realm, as Sign in from a native app describes. That is production's realm on an application with one environment, or development's where you turned it on.
Expected result
npm test runs the suite with no environment file, no connection, and no development database. Each test file starts one engine in about a second, and its cases run in milliseconds. Each case starts on empty tables after reset(), and the same input gives the same result on every run, with or without a connection.
The other doubles respond within the call, so their cases run in milliseconds, with the state and the recorded calls readable right after. A schedule fire returns as soon as the handler responds or the window ends.
Refusals
The doubles' refusals are errors in the test process. A Structured Query Language (SQL) fault includes the server's own five-character code, with the driver's message and members. Refusals lists the refusals the platform returns. Each client shows a refusal in its own way:
- The storage and egress clients throw, with the storage code in
codeand the gateway refusal's name inerror. - The logging client throws nothing. Read a refusal on the batch the double recorded, in its
statusandanswer.error, and onclient.stats().dropped. - The schedule double throws its codes before it sends anything.
- The AI allowance client throws
AllowanceRefusal, with the name inrefusal. - The account client returns
{ refused, detail }and throws nothing.
Where a refusal is the case's subject, assert its code instead of applying the remedy.
| Refusal | Cause | Remedy |
|---|---|---|
engine_required |
No engine was given, or its response to SELECT 1 lacks rows or a numeric rowCount. |
Pass a PGlite 0.5.8 instance as engine. |
session_held |
A second caller waited too long for a checked-out client. | Query through the client you hold, and release it in a finally. |
pool_ended |
connect() or query() was called after pool.end(). |
End the pool in afterAll alone. |
APP_DATABASE_URL absent |
The test imported a module that opens its pool at import. | Make the data layer a factory over a pool, as step 3 shows. Never set the variable for a test: only the process entry reads it. |
declarations_differ |
An area was declared again with other declarations. | Declare each area once per case, as the application does. |
mint_failed, its message starting 400: |
An area was declared with versionKeeping: true, or without application on a double created with application: null (application_required). |
Declare versionKeeping: false, and give application where the double represents a session. |
no_area |
A call named an area not declared since reset(). |
Declare the area in each case. |
no_file |
A read named a file the area does not contain. | Write the file in the arrange step. |
exists, version_moved |
A conditional write found the name taken or its version no longer current. | Read the version again before the write. |
no_identity |
A put or delete was called without identity. |
Pass the acting identity on every write. |
invalid_name |
A file name had a part that is . or .., the parts separated by a slash or a backslash, or an area name was . or ... From version 0.14.0, a file name given to put also held a backslash or a part that ends with a dot. The client checks this before the identity. |
Rename the file or the area. For a write, separate the parts with a forward slash, and end no part with a dot. |
put_failed, get_failed, list_failed, and the other _failed codes |
A scripted refusal, a body past requestBytes, or a list cursor that matches no file. |
Keep the body within the limit, and pass the cursor the previous page returned. |
mint_failed or another _failed code, its message starting 400: The request holds a NUL character |
A file name, a list prefix, or another part of a storage call's address contained a NUL character (U+0000), or an area declaration contained a NUL character or an unpaired UTF-16 surrogate. The platform refuses the same text. | Send the text without the character. |
invalid_batch, environment_undeclared, environment_mismatch, batch_too_large |
A malformed record, an environment the double does not accept, or a batch past its limits. | Use environment: 'local', and keep each record a level, a message, and flat fields. |
rate_capped, log_capacity_reached |
The batch passed batchesPerMinute or retainedCapacityBytes. |
Raise the limit. |
is not a refusal the logging service answers |
refuseNext named a refusal the service does not have. |
Use a refusal the service has, or a bare status. |
undeclared_upstream, unknown_upstream |
The call named an upstream the test never declared, or none. A name containing a NUL character (U+0000) or an unpaired UTF-16 surrogate reads as no name. | Declare the upstream with double.declare, and send its name without the character. |
upstream_scope_refused |
The upstream is declared for an application the double's credential does not reach. | Declare it for the application of the double's scope. |
credential_not_in_custody |
The upstream was declared with credentialHeld: false. |
Declare it with the credential held. |
request_too_large, response_too_large |
The request or the response passed a size limit. | Keep the body within requestBytes or responseBytes. |
upstream_unreachable |
The handler threw, or responded with fail. |
Return a response from the handler. |
authentication_required, environment_required, environment_mismatch |
The double requires a bearer and got none, or the environment header is missing or wrong. | Send Bearer and the credential, and send the environment that the double's environment option gives. |
unmarked |
The path was off the keyed route. | Call through the client. |
TypeError from declare |
The upstream's name is outside the allowed form, or the options give no application (application_required), or an empty or non-string application (invalid_request). |
Use the name the application declares, and pass { application }, the application's id as a string. |
schedule_undeclared |
fire or headersFor named an undeclared schedule. |
Fire a name the manifest declares. |
run_shape |
The run given is empty or contains a comma or whitespace. |
Pass a plain token, or omit it. |
due_shape |
The due given does not parse as an instant. |
Pass an ISO 8601 text or a Date, or omit it. |
handler_undeclared, schedule_unhandled, path_collision |
The manifest and the handlers disagree. | Make manifest.json and the handlers agree. |
scheduled_handler_only |
request reached a declared path without the four headers of that path's schedule. |
Pass headersFor(schedule, due), or use fire. |
allowance_exhausted |
used reached quota. |
Set a larger quota. |
allowance_unavailable, allowance_busy, plan_quantity_unset, allowance_application_required |
The double was given keyHeld: false or credential: 'session', or a script named the refusal. |
Leave the key held and the credential at the default. |
allowance_path_refused, allowance_feature_refused, gateway_refused |
The request named a model the double does not accept, asked for another feature, or left the route. | Pass model to the factory, and call generateText. |
environment_required, environment_mismatch from the AI allowance double |
The environment header is missing or differs from the factory's environment. |
Send the factory's environment on the transport. |
no primed answer, TypeError from prime |
No answer was queued, or a queued answer has the wrong form. | Queue an answer with prime after reset(). |
session_ended, session_revoked, session_expired |
The session is unknown, or a session of the double's realm is signed out, under revoked keys, or past lifeSeconds. |
Sign the user in with signIn in the case, and never sign a token yourself. |
user_suspended, session_other_realm, realm_mismatch |
The user was suspended, or the sign-in named another realm. The route returns session_other_realm for that session even once it is signed out or expired. |
Sign in an active user in the double's realm. |
authentication_required from the account double, not_owner |
The bearer is not the double's credential, or the owner read's bearer was not granted as 'owner'. |
Use double.client(), and pass grantBearer('owner') to verifyOwner. |
invalid_request from the account double, its detail starting The request holds a NUL character |
An accounts call's address contained a NUL character (U+0000), or its JSON body contained a NUL character or an unpaired UTF-16 surrogate. The double refuses it before it reads the credential, as the platform does. | Send the text without the character. |
TypeError from createAccountDouble |
The credential given is not a valid credential form. |
Leave the default credential. |
key_unknown, key_revoked, signature_invalid, malformed |
The sessionHeader passed does not match a current key and token. |
Pass headerFor(session), or the header from signIn. |
http_503 and the other http_ codes |
refuseNext scripted a bare status. |
Assert the code. |
issue_tracking_call_refused, issue_tracking_application_required, issue_tracking_not_provisioned, issue_tracking_unavailable |
view.refuseNext scripted the gateway's refusal, or the call named a path the gateway does not forward, such as delete_space. |
Call through the client, and assert the refusal where the case scripts it. |
issue_tracking_not_provisioned, unscripted |
The view's space does not exist: it was never seeded, or reset() emptied it. The view returns this refusal on its own for every call to a space that does not exist. |
Seed the space with double.createSpace() and take its view again in each case. |
is not a refusal the accounts service answers, no user <id> is signed in, no session is held |
A script or state change named something the double does not have. | Use a refusal the double has, and sign the user in first. |
Related
- The Database, Storage, Logging, Egress, Schedule, AI Allowance, and Account pages state what each package provides.
- Add a database covers the declaration, the setting, the migration files, and the pool's size.
- Run locally covers the environment file and the settings a live local run reads.
- Use the library installs a package's compiled code, the
testingsubpath with it. - Test double, in the glossary, defines a test double.