Skip to content

Test an application

How-to. The lesson that fronts this recipe: Configure and test. 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/__tests__/test-fixtures.ts

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). There is no unit option to pass any more: a unit module rides the composition root's own unit field, so boot takes the real module unchanged and the fork is whichever runtime forks it — the fixture supplies nothing about it either way:

src/api.spec.ts

ts
import { describe, expect } from "vitest";
import { it } from "./__tests__/test-fixtures.js";

describe("order-api", () => {
  it("answers a real oRPC call on an ephemeral port", async ({ boot, tokenFor }) => {
    // GIVEN the real composition root, bound to a loopback port the OS picks
    const app = boot(OrderApi);
    const info = (await app.runtimeInfo()).get();
    const client = createOrderApiClient(
      `http://127.0.0.1:${info?.port}`,
      "/rpc",
      {
        authorization: `Bearer ${await tokenFor()}`,
      },
    );

    // WHEN a call goes over the wire
    // THEN it reached the use case behind the transport
    await expect(
      client.orders.place({
        id: "0199a1e0-0000-7000-8000-000000000001",
        quantity: 2,
      }),
    ).toBeOkWith({
      id: "0199a1e0-0000-7000-8000-000000000001",
      quantity: 2,
    });
  });
});

The credentials are not optional: the contract marks its orders fragment authenticated, so the same call without an authorization header is refused before any procedure runs — and UNAUTHORIZED is not an error the contract declares, so it arrives as a Defect rather than in errCases. What the token establishes here is the tenant; the example's fixtures wrap this up as clientFor, which is why every spec below takes that fixture rather than building a client by hand. See Protect a procedure.

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:

ts
it("broadcasts every committed write, end to end", async ({
  serve,
  tapped,
  writer,
}) => {
  // GIVEN the real graph, tapped on the client the running app writes through
  await serve(tapped.module);

  // WHEN an order is placed in a scope over that client, for this test's own
  // tenant — the shape a unit module has
  // THEN it commits the very rows the relay sweeps, so the fact crosses the
  // outbox, the broker and the queue
  await expect(
    writer((ctx) =>
      ctx.get(PlaceOrder).execute("0199a1e0-0000-7000-8000-000000000001", 2),
    ),
  ).toBeOkWith(
    expect.objectContaining({ id: "0199a1e0-0000-7000-8000-000000000001" }),
  );
});

A port the module does not export is refused at the call site by the tap gate, 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:

ts
const lines: Line[] = [];

// The REAL root, with only its logger substituted — `overridden` replaces the
// provider by port, and fails loudly ("nothing to override") the day the root
// stops providing `Logger`. `"trace"` pinned rather than bound: the fixture's
// `LOG_LEVEL` silences the real root, and this one exists to be read.
const recordingApi = overridden(OrderApi, [
  Provider(Logger)({
    inject: {},
    value: createLogger((line) => lines.push(line), "trace"),
  }),
]);

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: "0199a1e0-0000-7000-8000-000000000001", quantity: 1 })
    .flatMap(() =>
      client.orders.place({
        id: "0199a1e0-0000-7000-8000-000000000002",
        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 });
});

It used to be a parallel root, because nothing can be layered over a graph that already provides Logger — and the hand-kept mirror is exactly what overridden retired: the real OrderApi with only its Logger provider substituted, and a loud WiringDefect the day the root stops providing it. 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.

Read the contract-less surfaces with fetch

The typed client is the right tool for every procedure — inputs typed by the contract, errors Result-shaped. What it cannot speak is the surface that has no contract: the kernel's probes (/livez, /readyz), a refusal's bare status code, the headers the listener sets before dispatch. Node's own fetch is the client there, aimed at the origin the booted app published — no dependency, since PORT=0 plus runtimeInfo() already is a "random port" boot and a request library's own would skip the config binding, the lifecycle and the drain that make the booted test worth having. examples/order-api's fixtures hand out both forms: probesFor(app) is a fetch bound to the probe server's port, and origin is the served real root as the string a URL is built from:

ts
it("refuses the anonymous caller on the wire", async ({ origin }) => {
  // GIVEN the real root, served by the `origin` fixture

  // WHEN the marked procedure is called with no credential at all
  const response = await fetch(`${origin}/rpc/orders/place`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      json: { id: "0199a1e0-0000-7000-8000-000000000001", quantity: 1 },
    }),
  });

  // THEN it was refused before any use case ran
  expect(response.status).toBe(401);
});

The status is read off the response and asserted once, as a projection. A redirect is read the same way with redirect: "manual", which is what keeps a 303 a 303 rather than the page it points at.

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).

MemberWhat it gives you
modulea Module<TestRuntimePort, never, never> providing this runtime
untilStarted()resolves the first time the kernel calls startAsyncResult<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
ts
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)({
      inject: {},
      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/__tests__/test-fixtures.ts starts from bootFixture and layers the example's own fixtures on top of boot:

ts
export const it = test.extend<ApiFixtures>({
  // One issuer per spec file: a served JWKS and a matching signer, so the
  // `user` scheme does a real fetch and a real verify.
  issuer: [localIssuerFixture, { scope: "file" }],

  env: async ({ issuer }, use) => {
    await use({
      PORT: "0",
      HOST: "127.0.0.1",
      LOG_LEVEL: "fatal",
      HTTP_JWT_JWKS_URI: issuer.jwks,
      HTTP_JWT_ISSUER: issuer.issuer,
      HTTP_JWT_AUDIENCE: issuer.audience,
    });
  },

  boot: async ({ env }, use) => {
    await bootFixture({ env })({}, use);
  },

  serve: async ({ boot }, use) => {
    await use((module, options) => boot(module, options));
  },
  // …clientFor, probesFor, statusOf, api, unmodelled, gate, recording
});

issuer is @btravstack/testing/jwt's localIssuer: a generated key pair, a node:http listener answering its public half as a JWKS document, and a signer over the private half — so the user scheme fetches and verifies for real, and a token from another issuer or past its exp is refused by jose rather than by a double. It is file-scoped because a key pair and a listener are worth building once per spec file, and nothing in the application is substituted for any of it: env carries the issuer's own three values under HTTP_JWT_JWKS_URI, HTTP_JWT_ISSUER and HTTP_JWT_AUDIENCE, which is exactly what a deployment sets, and localIssuerFixture is the ordinary fixture body that closes it at the end of the file.

serve has nothing to add over bootRequestModule is forked by the answerers themselves, per OrderApi's own unit option, not by anything a fixture supplies — so its shutdown is still the fixture's; clientFor builds the oRPC client from runtimeInfo() and gives it a token that issuer signed for this test's tenant (tokenFor, whose claims a spec overrides one at a time), since the contract marks the orders fragment and an anonymous call to it never reaches a use case; and recording is the real root's composition with a recording sink in place of stdout:

ts
const recordingApi = () => {
  const recorder = recorderOf();
  return {
    api: overridden(OrderApi, [
      Provider(Logger)({
        inject: {},
        value: createLogger(recorder.sink, "trace"),
      }),
    ]),
    lines: recorder.lines,
  };
};

The tenant a handler serves is not an input field: the orders handlers open a unit whose Tenant came from the principal, the value the authenticator resolved from the request's headers, and the marked fragment's inputs declare no tenant at all. So a spec's tenant reaches the server through the credential — the token clientFor mints, or the key serviceClientFor presents, which is cut for one fixed tenant — and never through an input. The unmarked customers fragment still names its tenant on the input, which is why its calls still pass one.

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:

SystemWhat a test getsMinted by
RabbitMQa vhost per test@amqp-contract/testing's it extension
Temporala namespace per file@btravstack/internal-test-infra/namespace
PostgreSQLa tenant per testthe 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.

A tenant needs no machinery of its own either: it is a Tenant PORT the example application declares, provided by whatever opened the unit — so a spec provides one the same way, and the repository it builds is bound to it:

ts
export const it = test.extend<{ tenant: TenantId }>({
  // oxlint-disable-next-line no-empty-pattern -- depends on no other fixture
  tenant: async ({}, use) => {
    await use(TenantId(uuidv7()));
  },
});
ts
it("reads back only its own tenant's order", async ({ repository, anOrder }) => {
  // GIVEN an order saved through a repository BUILT for this test's tenant
  // WHEN it is read back
  const found = await repository
    .save(anOrder("0199a1e0-0000-7000-8000-000000000001", 3))
    .flatMap(() => repository.find("0199a1e0-0000-7000-8000-000000000001"));

  // THEN the round trip is lossless, and scoped
  expect(found).toBeOkWith({
    id: "0199a1e0-0000-7000-8000-000000000001",
    quantity: 3,
  });
});

That is the whole fixture. TenantId is examples/order-domain's z.uuidv7().brand("TenantId"), and uuidv7() is @btravstack/internal-test-infra's — crypto.randomUUID() mints a v4, which the schema rejects. Where a spec exercises the layer rather than one adapter, the per-tenant SCOPE is what a fixture builds — tenantOf(tenant) beside the vertical, the shape a deployment's unit module has — and a spec that needs two tenants opens two scopes over one store. See Multi-tenancy is the application's, not the framework's for why the tenant is the application's own 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.

RuleWhy
describe is the first statementhelpers above it are invisible state a test silently depends on
helpers are fixtures via test.extend, in a sibling test-fixtures.tswhat 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, // THENsetup is not read as the subject; a test that cannot split is testing two things
one deep expect per test, on one resourceexpect(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

Released under the MIT License.