@btravstack/testing
v0.39.0
Published
Test the applications @btravstack/core boots: an in-memory runtime, a fake clock, a boot fixture that stops what it started, and a tap into a running graph
Maintainers
Readme
@btravstack/testing
Test the applications
@btravstack/coreboots. Atest.extendfixture that starts a module with a test's defaults and stops every app it started when the test ends, a tap that hands back the very services a booted graph holds, an in-memory runtime, a clock that moves on demand — the way@nestjs/testingis to NestJS, and kept out of the kernel so a production bundle never pulls the fakes in.
📖 Documentation · Reference · API Reference
pnpm add -D @btravstack/testing @btravstack/core @btravstack/config @btravstack/di unthrown@btravstack/core, @btravstack/config, @btravstack/di and unthrown are
peer dependencies; the package depends on nothing else — not even vitest:
bootFixture is a plain (ctx, use) => Promise<void> function, which is
vitest's fixture protocol, so no import is needed to hand it to test.extend.
jose is an optional peer behind the @btravstack/testing/jwt subpath —
see below — so a consumer that never imports it installs nothing extra. Node
>=22.
A booted application, as a fixture
import { bootFixture, tapped, type Boot } from "@btravstack/testing";
import { expect, test } from "vitest";
const it = test.extend<{ boot: Boot }>({
boot: bootFixture({ env: { PORT: "0", HOST: "127.0.0.1" } }),
});
it("serves on an ephemeral port", async ({ boot }) => {
const app = boot(OrderApi);
await expect(app.runtimeInfo()).toBeOkWith(
expect.objectContaining({ port: expect.any(Number) }),
);
});
it("holds the very logger the use cases write to", async ({ boot }) => {
const tap = tapped(OrderApi, [Logger]);
await boot(tap.module).runtimeInfo();
const [logger] = tap.services();
expect(logger.lines()).toEqual([]);
});boot is start as a test hands it out: the same signature, the same
compile-time gate, minus signals — always off — with probes: false unless
a call asks for a port, preDrainDelayMs: 0 and a silent onEvent as
defaults, each overridable by the fixture's defaults and again per call.
When the test ends every app it started is stopped, on every exit path, a
failing assertion included; a Defect on exited fails the test even
when the test never looked at it, while a modeled Err — a startup failure
the test may be asserting — passes through.
tapped(module, [Port, …]) composes one more provider around module,
depending on those ports, and remembers what it was built with: services()
answers the very instances the running graph holds once it is built — after
runtimeInfo() resolves, say — and throws before. The gate refuses a port
module does not export.
What it ships
bootFixture(defaults?)— the fixture above;BootandBootDefaultsare its types.tapped(module, ports)→{ module, services() }— read services out of a booted application.testRuntime(name?, { unit? })/TestRuntimePort— an in-memoryRuntimewithsubmit()to hold a unit open across a drain,host()to reach theRuntimeHostit was last started with, and amodulethat provides it where a starter would;unitis a module every submitted unit forks, with no seed, before its work runs — and what that module needs joinsmodule's own, sostartnames it instead of the firstsubmit()failing.overridden(module, [providers])— substitute named providers into your real composition root, rather than maintaining a parallel test root beside it. It replaces one provider, never a subsystem: the replaced provider's siblings still construct, so swapping a whole adapter stack is still a different module composed in its place. An override for a port the tree stopped providing is aWiringDefect("nothing to override"), which is what keeps a test root from drifting silently away from the real one — the drift that four hand-maintained parallel roots had already produced here.overridden(module, [providers], { unit: { user: [providers] } })reaches inside a unit module the root binds, by kind, with the same drift gate at boot.createFakeClock(start?)— aClockforStartOptions.clockwhose time moves only onadvance(ms).
The teardown rule: a Defect on exited fails the test, a modeled Err does
not.
A local JWT issuer, on its own subpath
import { localIssuer } from "@btravstack/testing/jwt";
const issuer = await localIssuer({
issuer: "https://issuer.test",
audience: "orders-api",
}).get();
const token = await issuer.sign({ sub: "u-1" }).get();
// verify `token` against `issuer.jwks`, the same way a real JWKS would be
await issuer.close();@btravstack/testing/jwt's localIssuer(options) mints a generated key
pair, a node:http listener answering its public key as a JWKS, and a
sign(claims?, options?) closing over the private key — a real fetch against
a real JWKS document, for a test that wants the verifying library's own
behaviour rather than a double's. sign's expiresIn: false mints a token
with no exp claim, and the key is RS256. The subpath needs Node ≥22.12 under CommonJS — jose is
ESM-only, so the CJS build's require("jose") depends on require(esm);
ESM consumers, and anyone who never imports the subpath, are unaffected.
License
MIT © Benoit TRAVERS
