Skip to content

Configure and test

Tutorial. The second hands-on lesson. It assumes you finished Getting started and have greeter.ts, contract.ts, router.ts, app.ts and main.ts from it. We keep explanation to a minimum here and link out to it.

By the end, the greeting word will come from the environment the way PORT already does — validated once, as the graph is built — and a test will boot the real application on an ephemeral port, call it through the typed client, and drain it on a clock that never actually waits.

Step 1 — Install the test harness

sh
pnpm add -D @btravstack/testing vitest @unthrown/vitest
sh
npm install -D @btravstack/testing vitest @unthrown/vitest
sh
yarn add -D @btravstack/testing vitest @unthrown/vitest

Configuration needs nothing new: Config lives in @btravstack/config, which you installed in lesson one.

Step 2 — Bind a setting of your own

Lesson one hard-coded Hello. Make it a setting: Config.provider mints a port and binds it from the Env port the kernel provides, validating once, as the graph is built — your code never touches process.env:

greeter.ts

ts
import { Config, Env } from "@btravstack/config";
import { Module, Port, Provider } from "@btravstack/di";

const greetingConfig = Config.provider("GreetingConfig")(
  Config.object({
    greeting: Config.string("GREETING", { default: "Hello" }),
  }),
);

export class Greeter extends Port("Greeter")<{
  readonly greet: (name: string) => string;
}> {}

export const GreetingModule = Module("Greeting")({
  needs: [Env],
  provides: [
    greetingConfig,
    Provider(Greeter)({
      inject: { config: greetingConfig.port },
      sync: ({ config }) => ({
        greet: (name) => `${config.greeting}, ${name}!`,
      }),
    }),
  ],
  exports: [Greeter],
});

Three changes from lesson one. greetingConfig is a provider whose port carries { greeting: string }; the Greeter provider now declares it as a dependency and closes over the value; and the module says needs: [Env] out loud — its providers read the environment port, and nothing inside the module supplies it. The kernel does, to every graph it boots (the rule).

Nothing else changes: router.ts, app.ts and main.ts still compile, because the module's exports did not move. Run it:

sh
GREETING=Ahoy PORT=3000 npx tsx src/main.ts

and client.ts from lesson one now prints Ahoy, world!. Try GREETING="" too: an empty variable is a configuration error, not an absent one — the process prints a startFailed event naming GREETING and exits 78 (why).

Step 3 — Register the matchers

One config file, once. @unthrown/vitest registers Result matchers (toBeOkWith, toBeErrTagged, …) as a vitest setup file, so a test asserts on a Result in one deep expect:

vitest.config.ts

ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { setupFiles: ["@unthrown/vitest"] },
});

Step 4 — Boot the application in a test

@btravstack/testing's bootFixture is start in test shape: it boots the real module — the same App — with signals off and an environment you choose, and stops it when the test ends, on every path. PORT=0 asks the operating system for an ephemeral port, so tests never collide:

app.spec.ts

ts
import assert from "node:assert/strict";

import { bootFixture } from "@btravstack/testing";
import { describe, expect, test } from "vitest";

import { App } from "./app.js";

const it = test.extend({
  boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
});

describe("the greeting service", () => {
  it("serves the configured greeting", async ({ boot }) => {
    // GIVEN the real application, with this test's own environment
    // `env` REPLACES the fixture's rather than merging, so `PORT`/`HOST`
    // are restated here.
    const app = boot(App, {
      env: { PORT: "0", HOST: "127.0.0.1", GREETING: "Ahoy" },
    });
    const info = (await app.runtimeInfo()).get();
    assert.ok(info !== undefined, "the runtime published no Serving.info");

    // WHEN the procedure is called over real HTTP
    const response = await fetch(`http://127.0.0.1:${info.port}/rpc/hello`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ json: { name: "world" } }),
    });

    // THEN the greeting came from the environment, not the code
    await expect(response.json()).resolves.toEqual({
      json: { message: "Ahoy, world!" },
    });
  });
});

Run it:

sh
npx vitest run

A call's env REPLACES the fixture's, it does not merge with it. The options are spread — the fixture's defaults first, then the call's — so { env: { GREETING: "Ahoy" } } is the whole environment this application sees, PORT and HOST included. That is why the call above cannot simply add GREETING on top: spell out every variable the test needs, or leave env off the call and let the fixture's stand. It is per option, so a call may override env and still take the fixture's probes and preDrainDelayMs.

Two things to notice. The test booted the whole application — the graph, the config validation, the HTTP listener — not a handler in isolation; and runtimeInfo() is how it learned the port the runtime actually bound, published once the process is serving — get() plus an assertion is the shape, since its error channel is empty and undefined only means the runtime never reached serving, which deserves a named failure rather than a confusing fetch error. The raw fetch shows there is no magic; in your own suite, hand the origin to lesson one's typed client instead.

Step 5 — Drain it on a fake clock

Lesson one stopped the process with a signal and a real five-second wait. A test stops it with a method — and a clock it controls, so the wait costs nothing:

drain.spec.ts

ts
import { bootFixture, createFakeClock } from "@btravstack/testing";
import { expect, test } from "vitest";

import { App } from "./app.js";

const it = test.extend({
  boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
});

it("drains clean when idle", async ({ boot }) => {
  // GIVEN the application serving, on a clock this test owns
  const clock = createFakeClock();
  const app = boot(App, { clock });
  await app.runtimeInfo();

  // WHEN it is asked to drain and the pre-drain delay is advanced, not waited
  app.requestDrain();
  await clock.advance(5_000);

  // THEN it exits clean, with nothing abandoned
  await expect(app.exited).resolves.toBeOkWith(
    expect.objectContaining({
      reason: "signal",
      drain: expect.objectContaining({ abandoned: 0 }),
    }),
  );
});

requestDrain() takes the same path SIGTERM does — readiness flips, the kernel waits preDrainDelayMs, in-flight work gets its window — but the wait happened on clock.advance(5_000), which resolves immediately. A kernel whose own tests are slow gets tested badly, so timing is never real in a test (Test an application).

What you now have

text
src/greeter.ts     GreetingModule — reads GREETING inside the graph
src/app.spec.ts    boots the real App on port 0, asserts through real HTTP
src/drain.spec.ts  drains it on a fake clock, asserts the exit report

The application still has one composition root and one entry point; what grew is the proof around it.

Where next

Released under the MIT License.