Integration testing

Own typed in-process worlds or one runtime shared safely across isolated test workers.

Use a stateful emulator only when the assertion needs the provider boundary. Choose the right test boundary separates those tests from unit tests and fakes.

Start one typed in-process world

This complete test owns the runtime, one seeded instance, one control-plane arrangement, and one provider-shaped application request:

test/owned-runtime.test.ts
import { createTestRuntime } from "localhost2137/testing";
import { expect, it } from "vitest";
import config from "../localhost.config.js";

it("arranges a user with an operation and reads it through Slack-shaped HTTP", async () => {
	const runtime = await createTestRuntime({
		config,
		port: 0,
		storage: "temporary",
	});

	try {
		const instance = await runtime.createInstance({ seed: true });
		try {
			const grace = await instance.slack.createUser({ name: "Grace" });
			const response = await fetch(new URL("users.list", instance.slack.connection.apiUrl), {
				headers: {
					authorization: `Bearer ${instance.slack.connection.botToken}`,
				},
			});

			expect(response.status).toBe(200);
			await expect(response.json()).resolves.toMatchObject({
				members: [
					{ id: "U000000", name: "localhost2137-bot" },
					{ id: grace.id, name: "Grace" },
					{ id: "U_ADA", name: "Ada" },
				],
				ok: true,
			});
		} finally {
			await instance.destroy();
		}
	} finally {
		await runtime.close();
	}
});
pnpm exec vitest run test/owned-runtime.test.ts

Know what the test runtime owns

The nested finally blocks are the ownership model. After createTestRuntime resolves, that scope must call runtime.close(). After createInstance resolves, its narrower scope must destroy the instance first. The exact runtime options select loopback, an OS-assigned port, and owned temporary storage. It does not discover or attach to localhost dev.

The typed handle exposes configured operations, connection values, env, seed(), reset({ seed }), idle(), clock control, and destroy(). Operations arrange and inspect; the HTTP request above proves the application-facing path. Do not replace the behavior under test with its control operation or read plugin databases directly.

runtime.connection contains the loopback control URL and private bearer token for crossing a process boundary. runtime.control is the corresponding untyped client. Neither is application configuration. A failed temporary-directory removal is reported as TestRuntimeCleanupError with retainedStoragePath; treat it as a cleanup failure and retained diagnostic evidence.

Exercise an SDK and virtual time

This checked example arranges a product and price through typed operations, creates the customer and subscription through the official SDK, advances the instance clock, and reads invoices through the SDK again:

test/subscription.test.ts
import { createTestRuntime } from "localhost2137/testing";
import { afterEach, describe, expect, it } from "vitest";
import config from "../localhost.config.js";
import { createLocalStripe } from "../src/local-stripe.js";

const runtimes: Array<Awaited<ReturnType<typeof createTestRuntime>>> = [];

afterEach(async () => {
	await Promise.all(runtimes.splice(0).map((runtime) => runtime.close()));
});

describe("official Stripe SDK", () => {
	it("creates and renews a subscription through normal SDK calls", async () => {
		const runtime = await createTestRuntime({ config, port: 0, storage: "temporary" });
		runtimes.push(runtime);
		const instance = await runtime.createInstance();
		try {
			const product = await instance.stripe.createProduct({ name: "Pro" });
			const price = await instance.stripe.createPrice({
				productId: product.id,
				unitAmount: 2_500,
			});
			const client = createLocalStripe(instance.stripe.connection);

			const customer = await client.customers.create({
				email: "ada@example.test",
				name: "Ada",
			});
			await expect(client.customers.retrieve("cus_missing")).rejects.toMatchObject({
				code: "customer_missing",
				statusCode: 404,
				type: "StripeInvalidRequestError",
			});
			const subscription = await client.subscriptions.create({
				customer: customer.id,
				items: [{ price: price.id }],
			});
			expect(subscription).toMatchObject({
				customer: customer.id,
				id: "sub_000001",
				latest_invoice: "in_000001",
				status: "active",
			});

			await instance.clock.advance("30d");
			const invoices = await client.invoices.list({
				limit: 10,
				subscription: subscription.id,
			});
			expect(invoices.data.map(({ id }) => id)).toEqual(["in_000001", "in_000002"]);
			expect(invoices.data[1]).toMatchObject({
				amount_paid: 2_500,
				customer: customer.id,
				paid: true,
				subscription: subscription.id,
			});
		} finally {
			await instance.destroy();
		}
	});
});
pnpm --filter @localhost2137/example-stripe-sdk test

Clock advancement is a stronger transition than an operation call: it returns after all configured services reconcile the committed time window and their tracked work. For asynchronous work without a clock change, await instance.idle() waits for tracked tasks, including nested tracked tasks. It cannot discover arbitrary fire-and-forget promises. Do not add sleeps around either transition; see Virtual time and asynchronous work.

Reuse the runtime, not the world

A file may create one runtime in beforeAll and close it in afterAll. Each test should still call createInstance() and destroy that instance in finally. Separate worlds isolate state, seed status, clock, logs, and storage on the runtime's one server port.

Use reset() only when replacing the same world is part of one scenario. It produces an empty world; reset({ seed: true }) produces a freshly seeded replacement. Seed is also explicit on createInstance({ seed: true }) and instance.seed(). Await lifecycle calls rather than overlapping seed, reset, clock advancement, and destruction.

Share one runtime across worker processes

Vitest global setup owns the runtime. Workers receive only a frozen connection and each selects a unique instance ID. The provided context makes the process boundary explicit:

test/runtime-connection.ts
export interface WorkerRuntimeHarness {
	readonly barrier: Readonly<{
		readonly directory: string;
		readonly participants: number;
	}>;
	readonly connection: Readonly<{
		readonly token: string;
		readonly url: string;
	}>;
}

declare module "vitest" {
	export interface ProvidedContext {
		readonly localhost2137: WorkerRuntimeHarness;
	}
}

Global setup closes partially created resources as well as successful setup:

test/global-setup.ts
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createTestRuntime } from "localhost2137/testing";
import type { TestProject } from "vitest/node";
import { config } from "../src/config.js";
import type { WorkerRuntimeHarness } from "./runtime-connection.js";

export default async function setup(project: TestProject): Promise<() => Promise<void>> {
	const barrierDirectory = await mkdtemp(join(tmpdir(), "localhost2137-vitest-barrier-"));
	let runtime: Awaited<ReturnType<typeof createTestRuntime>> | undefined;
	try {
		const ownedRuntime = await createTestRuntime({ config, port: 0, storage: "temporary" });
		runtime = ownedRuntime;
		const harness: WorkerRuntimeHarness = Object.freeze({
			barrier: Object.freeze({ directory: barrierDirectory, participants: 4 }),
			connection: ownedRuntime.connection,
		});
		project.provide("localhost2137", harness);
		return () => closeOwnedResources(ownedRuntime, barrierDirectory);
	} catch (cause) {
		const failures = await cleanupFailures(runtime, barrierDirectory);
		if (failures.length > 0) {
			throw new AggregateError([cause, ...failures], "Parallel example setup and cleanup failed.");
		}
		throw cause;
	}
}

async function closeOwnedResources(
	runtime: Awaited<ReturnType<typeof createTestRuntime>>,
	barrierDirectory: string,
): Promise<void> {
	const failures = await cleanupFailures(runtime, barrierDirectory);
	if (failures.length > 0) throw new AggregateError(failures, "Parallel example cleanup failed.");
}

async function cleanupFailures(
	runtime: Awaited<ReturnType<typeof createTestRuntime>> | undefined,
	barrierDirectory: string,
): Promise<unknown[]> {
	const failures: unknown[] = [];
	await runtime?.close().catch((cause: unknown) => failures.push(cause));
	await rm(barrierDirectory, { force: true, recursive: true }).catch((cause: unknown) =>
		failures.push(cause),
	);
	return failures;
}

The worker helper owns only a caller-selected ID. Its create failure handling distinguishes a known server rejection from an uncertain response:

test/owned-instance.ts
import { ControlApiError, type RuntimeClient } from "localhost2137/client";

type InstanceOwnerClient = Pick<RuntimeClient, "createInstance" | "destroyInstance">;

type Outcome<Value> =
	| Readonly<{ ok: true; value: Value }>
	| Readonly<{ cause: unknown; ok: false }>;

/** Own one known worker instance ID without deleting an authoritative conflict. */
export async function withOwnedInstance<Value>(
	runtime: InstanceOwnerClient,
	instanceId: string,
	use: () => Promise<Value>,
): Promise<Value> {
	try {
		await runtime.createInstance({ id: instanceId, persistence: "ephemeral" });
	} catch (cause) {
		if (cause instanceof ControlApiError) throw cause;
		return finishOwnership(runtime, instanceId, { cause, ok: false });
	}

	let primary: Outcome<Value>;
	try {
		primary = { ok: true, value: await use() };
	} catch (cause) {
		primary = { cause, ok: false };
	}
	return finishOwnership(runtime, instanceId, primary);
}

async function finishOwnership<Value>(
	runtime: InstanceOwnerClient,
	instanceId: string,
	primary: Outcome<Value>,
): Promise<Value> {
	const cleanup = await destroyIfPresent(runtime, instanceId);
	if (!primary.ok) {
		if (!cleanup.ok) {
			throw new AggregateError(
				[primary.cause, cleanup.cause],
				`Worker instance ${JSON.stringify(instanceId)} failed and cleanup also failed.`,
				{ cause: primary.cause },
			);
		}
		throw primary.cause;
	}
	if (!cleanup.ok) throw cleanup.cause;
	return primary.value;
}

async function destroyIfPresent(
	runtime: InstanceOwnerClient,
	instanceId: string,
): Promise<Outcome<void>> {
	try {
		await runtime.destroyInstance(instanceId);
		return { ok: true, value: undefined };
	} catch (cause) {
		if (cause instanceof ControlApiError && cause.code === "INSTANCE_NOT_FOUND") {
			return { ok: true, value: undefined };
		}
		return { cause, ok: false };
	}
}

Each worker connects, creates one unique ID, asserts isolated state, waits for tracked work, and lets the helper clean up:

test/worker-contract.ts
import { randomUUID } from "node:crypto";
import { connectRuntime } from "localhost2137/client";
import { describe, expect, inject, it } from "vitest";
import { arriveAtBarrier } from "./barrier.js";
import { withOwnedInstance } from "./owned-instance.js";
import "./runtime-connection.js";

export function defineWorkerContract(label: string, increment: number): void {
	describe(label, () => {
		const harness = inject("localhost2137");
		const runtime = connectRuntime(harness.connection);
		const instanceId = `parallel-${randomUUID()}`;

		it("owns isolated state on the shared runtime", async () => {
			await withOwnedInstance(runtime, instanceId, async () => {
				await expect(runtime.executeOperation(instanceId, "counter", "read", {})).resolves.toEqual({
					value: 0,
				});
				await expect(
					runtime.executeOperation(instanceId, "counter", "increment", { by: increment }),
				).resolves.toEqual({ value: increment });
				await arriveAtBarrier(
					harness.barrier.directory,
					label.split(" ")[0] ?? label,
					harness.barrier.participants,
				);
				await expect(runtime.executeOperation(instanceId, "counter", "read", {})).resolves.toEqual({
					value: increment,
				});
			});
		});
	});
}

The counter operations finish synchronously, so this worker has no tracked work to await. The callback-driven test in examples/slack-ping-bot/test/ping-pong.test.ts calls instance.idle() before asserting delivery; What idle() establishes defines that boundary.

A create ControlApiError is an authoritative server rejection. The helper rethrows it without running the scenario or destroying that ID; an INSTANCE_CONFLICT can name a world the worker does not own. A transport or protocol failure leaves the outcome uncertain: creation may have committed before the response was lost, so the helper reconciles only the ID it selected.

After a successful or uncertain create, cleanup ignores only a ControlApiError with INSTANCE_NOT_FOUND. An AggregateError keeps a primary scenario failure first and as cause when cleanup also fails. The remote client is intentionally untyped, so operation names and inputs must come from the mounted plugin's inventory.

Keep the control token in the framework's private in-memory context. Do not log it, inject it into the application, or persist it in an artifact. Instances isolate state and time, not shared plugin config or one callback destination; Callbacks and webhooks covers that parallel boundary.

Run the checked four-worker example:

pnpm --filter @localhost2137/example-testing-parallel test

Use connectRuntime against a separately owned localhost dev daemon only for intentional local scripts. Such a script owns its unique ephemeral instance, never the daemon or someone else's persistent dev world. CLI workflow and reference documents that access.