Test an application
How-to. Boot a module in a test, drive its lifecycle deterministically, and assert on what the kernel reports. For why the harness is shaped this way, see Nothing throws and Draining, in three beats; for the full surface, see @btravstack/testing.
Everything you need is in @btravstack/testing, a dev dependency (pnpm add -D @btravstack/testing) that peers on @btravstack/core, @btravstack/config, @btravstack/di and unthrown — the copies your application already holds. Four tools: bootFixture boots and stops inside a vitest fixture, tapped reaches a service of a running graph (its lines come back through observability({ sink }) instead), testRuntime stands in for a transport, createFakeClock moves time when you say so.
Boot in a fixture with bootFixture
The recipe is one fixture module per package, exporting the it every spec imports:
// src/test-fixtures.ts
import { bootFixture, type Boot } from "@btravstack/testing";
import { test } from "vitest";
export const it = test.extend<{ boot: Boot }>({
boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
});boot is start with a test's defaults baked in — signals: false always, probes: false, preDrainDelayMs: 0, a silent onEvent — and every application it starts is stopped when the test ends, on every exit path. A call's own options win over the fixture's (boot(module, { probes: { port: 0 } }) binds an ephemeral probe port), and unit goes on the call, because a unit module is the composition's choice, not the fixture's:
// src/api.spec.ts
import { describe, expect } from "vitest";
import { it } from "./test-fixtures.js";
describe("order-api", () => {
it("answers a real oRPC call on an ephemeral port", async ({ boot }) => {
// GIVEN the real composition root, bound to a loopback port the OS picks
const app = boot(OrderApi, { unit: RequestModule });
const info = (await app.runtimeInfo()).get();
const client = createOrderApiClient(`http://127.0.0.1:${info?.port}`);
// WHEN a call goes over the wire
// THEN it reached the use case behind the transport
await expect(client.orders.place({ id: "o-1", quantity: 2 })).toBeOkWith({
id: "o-1",
quantity: 2,
});
});
});runtimeInfo() is whatever the runtime published on Serving.info — the HTTP starter publishes { port } — and probePort() the probe port that bound. Both carry E = never, so .get() is the whole read. The teardown is stop(), then exited is examined, and a Defect there fails the test even if the test never looked at exited; a modeled Err passes through, since a startup failure is an outcome you may be asserting.
Reach a running service with tapped
start hands the application context to the runtime alone, so a spec has no ctx.get to reach the very OrderRepository the running graph writes through. tapped(module, [Port, …]) composes one more provider around the module and hands back what it was built with; boot tap.module in place of the module and read tap.services() afterwards:
it("broadcasts every committed write, end to end", async ({ serve }) => {
// GIVEN the real graph, tapped on the writer the spec places orders through
const tap = tapped(OrderAmqpWorker, [PlaceOrder, OrderRepository, Outbox]);
await serve(tap.module);
const [placeOrder] = tap.services();
// WHEN an order is placed — one ordinary write, no publish in sight
// THEN it is the very instance the relay sweeps, so the fact crosses the
// outbox, the broker and the queue
await expect(placeOrder.execute("o-1", 2)).toBeOkWith(
expect.objectContaining({ id: "o-1" }),
);
});The gate refuses a port the module does not export (NOT EXPORTED, at the call site), and services() throws if read before the graph is built — a bug in the test, kept loud rather than answered with an undefined.
Read a running graph's log lines with a sink
A tap is the wrong tool for this, and examples/order-api uses none: @btravstack/observability's observability({ sink }) is the seam. The sink is a value the composition takes, so what a spec gets back is the Line itself — unit.traceId as a field rather than a prefix parsed out of a string. Compose the root's own shape with a recording sink, and boot that:
const lines: Line[] = [];
const recordingApi = HttpModule("RecordingApi")({
router: orderRouter,
imports: [
OrderApplicationModule,
OrderPersistenceModule,
// Pinned rather than bound: the fixture's `LOG_LEVEL` silences the real
// root, and this root exists to be read.
observability({ sink: (line) => lines.push(line), level: "trace" }),
],
exports: [Logger],
});
it("runs each call in its own unit, with its own trace id", async ({
serve,
clientFor,
}) => {
// GIVEN the real graph's composition, recording every line its logger writes
const client = await clientFor(serve(recordingApi));
// WHEN two calls are served — chained, so neither `Result` is dropped
const served = await client.orders
.place({ id: "o-1", quantity: 1 })
.flatMap(() => client.orders.place({ id: "o-2", quantity: 1 }));
// THEN four lines, two distinct trace ids, none written outside a unit
const traced = served.map(() => ({
lines: lines.length,
distinct: new Set(lines.map((line) => line.unit?.traceId)).size,
outOfUnit: lines.filter((line) => line.unit === undefined).length,
}));
expect(traced).toBeOkWith({ lines: 4, distinct: 2, outOfUnit: 0 });
});A parallel root rather than OrderApi itself, because nothing can be layered over a graph that already provides Logger. Give the fixture's own env a LOG_LEVEL: "fatal" so the real root — whose sink is the production jsonSink() on stdout — does not write into the runner's output. See Log and correlate.
Kernel-level: testRuntime and createFakeClock
To test the lifecycle itself — a drain, an abandonment, an exit report — you want no transport and no real clock. testRuntime(name?) is an in-memory Runtime<never, TestRuntimeInfo> whose module provides it on TestRuntimePort, so a test composition gets a runtime the way a real one does: import the module, export the port. createFakeClock() passed as clock makes the pre-drain delay and drain deadline elapse only on advance(ms).
| Member | What it gives you |
|---|---|
module | a Module<TestRuntimePort, never, never> providing this runtime |
untilStarted() | resolves the first time the kernel calls start — AsyncResult<void, never> |
started() | whether start has been called |
accepting() | false once drain or stop has been called — when the kernel told it to stop |
serving() | the Serving it handed the kernel (throws if not started — a bug in the test) |
submit<T, E>() | opens a unit and returns { settle, result, signal }, so you can hold it open across a drain |
import { Module, Port, Provider } from "@btravstack/di";
import {
TestRuntimePort,
bootFixture,
createFakeClock,
testRuntime,
type Boot,
} from "@btravstack/testing";
import { Ok } from "unthrown";
import { describe, expect, test } from "vitest";
const it = test.extend<{ boot: Boot }>({ boot: bootFixture() });
class Greeter extends Port("Greeter")<{
readonly greet: (name: string) => string;
}> {}
const AppModule = Module("App")({
provides: [
Provider(Greeter)({ value: { greet: (name: string) => `hello, ${name}` } }),
],
exports: [Greeter],
});
describe("draining", () => {
it("lets an in-flight unit finish inside the drain window", async ({
boot,
}) => {
// GIVEN the application composed with the in-memory runtime, on a fake clock
const clock = createFakeClock();
const runtime = testRuntime();
const TestApp = Module("TestApp")({
imports: [AppModule, runtime.module],
exports: [TestRuntimePort],
});
const app = boot(TestApp, { clock, preDrainDelayMs: 5_000 });
await runtime.untilStarted();
const unit = runtime.submit<string>();
// WHEN a drain is requested and the pre-drain delay elapses
app.requestDrain();
await clock.advance(5_000);
unit.settle(Ok("done"));
const report = await app.exited;
// THEN the unit is counted completed, not abandoned
expect(report).toBeOkWith(
expect.objectContaining({
drain: { inFlightAtStart: 1, completed: 1, abandoned: 0 },
}),
);
});
});To prove abandonment instead, never settle the unit and advance past the deadline too (await clock.advance(20_000)): the report reads abandoned: 1, because testRuntime ignores the drain signal deliberately and leaves the unit to the kernel.
toBeOkWith, toBeErrWith, toBeErrTagged and toBeDefectWith come from @unthrown/vitest; register them once through setupFiles and add import type {} from "@unthrown/vitest"; to a vitest.d.ts so they type.
How the examples do it
The examples do not fake the transport: they boot the real composition root on an ephemeral loopback port and talk to it with a typed client. examples/order-api/src/test-fixtures.ts starts from bootFixture and layers the example's own fixtures on top of boot:
export const it = test.extend<ApiFixtures>({
boot: bootFixture({
env: { PORT: "0", HOST: "127.0.0.1", LOG_LEVEL: "fatal" },
}),
serve: async ({ boot }, use) => {
await use((module, options) =>
boot(module, { unit: RequestModule, ...options }),
);
},
// …clientFor, probesFor, statusOf, api, unmodelled, gate, recording
});serve is boot with RequestModule forked around every request, so its shutdown is still the fixture's; clientFor builds the oRPC client from runtimeInfo(); and recording is the real root's composition with a recording sink in place of stdout:
const recordingApi = () => {
const recorder = recorderOf();
return {
api: HttpModule("RecordingApi")({
router: orderRouter,
imports: [
OrderApplicationModule,
OrderPersistenceModule,
observability({ sink: recorder.sink, level: "trace" }),
],
exports: [Logger],
}),
lines: recorder.lines,
};
};api.spec.ts then swaps the repository for a stub that holds a request open to prove completed: 1 and abandoned: 1 against the real HTTP runtime (see Swap an adapter for tests). The other two examples follow the same shape — boot: bootFixture(), a serve that adds the transport's own environment, tapped over the services the specs assert through and observability({ sink }) for the lines — and pay a fixture cost, stated in their READMEs: they need a Docker daemon.
Isolate by the boundary, not by the server
Every suite that needs a broker, a workflow platform or a database shares one of each across the whole repository, and isolates itself by the boundary that system already has:
| System | What a test gets | Minted by |
|---|---|---|
| RabbitMQ | a vhost per test | @amqp-contract/testing's it extension |
| Temporal | a namespace per file | @btravstack/internal-test-infra/namespace |
| PostgreSQL | a tenant per test | the workspace's own fixture, a UUID |
Starting a server per workspace instead is what made pnpm test intermittently red at turbo's default concurrency, and it bought an isolation these boundaries already gave for nothing.
The consequence worth planning for: nothing cleans up after a test. No truncate, no drop, no purge — a test that needed one would be a test sharing a namespace it should have minted. One migration runs for the whole gate, and the tests that share that schema never see each other's rows.
Reading a tenant back needs nothing at all, because the example application names it on its ports rather than reading it from ambient context:
export const it = test.extend<{ tenant: string }>({
// oxlint-disable-next-line no-empty-pattern -- depends on no other fixture
tenant: async ({}, use) => {
await use(`t-${randomUUID()}`);
},
});
it("reads back only its own tenant's order", async ({
tenant,
repository,
anOrder,
}) => {
// GIVEN an order saved under this test's tenant
// WHEN it is read back
const found = await repository
.save(tenant, anOrder("o-1", 3))
.flatMap(() => repository.find(tenant, "o-1"));
// THEN the round trip is lossless, and scoped
expect(found).toBeOkWith({ id: "o-1", quantity: 3 });
});That is the whole fixture. See Multi-tenancy is the application's, not the framework's for why the tenant is an argument rather than something the transport reads.
Follow the repo's test conventions
The specs in examples/ are read as advice, so they keep five rules; the last two bind everywhere.
| Rule | Why |
|---|---|
describe is the first statement | helpers above it are invisible state a test silently depends on |
helpers are fixtures via test.extend, in a sibling test-fixtures.ts | what a test needs arrives through its parameter list; fixtures are lazy |
teardown lives in the fixture, after await use(...) | it runs on every exit path, without a try/finally around the body |
every body carries // GIVEN, // WHEN, // THEN | setup is not read as the subject; a test that cannot split is testing two things |
one deep expect per test, on one resource | expect(r).toBeErr(); if (r.isErr()) {…} goes green when the narrowing is false; a projection cannot |
bootFixture is what the second and third rules asked for: a callback harness cannot be handed to use(), which is why every suite once hand-rolled the same start(...) plus stop(); expect(exited).toBeOk() — now the package's. Two resources means two tests. Waiting is not asserting: synchronise on a state with vi.waitUntil(() => app.phase() === "draining") and assert that state in the one expect.
See also
- @btravstack/testing — every member, with types.
- Swap an adapter for tests — compose a stub persistence module instead of overriding a provider.
- Order API (HTTP) — the spec and fixture files quoted above, in full.