Test app harness
Boot your full app in a single line and drive it from inside a test. createTestApp boots a fresh,
isolated AppHandle — reusing the Vite plugin's already-compiled wiring when it can (the fast path), or
compiling the app into a unique temp directory otherwise — all automatically cleaned up when the
await using block exits.
The harness is transport-neutral: HTTP is not assumed, and app.get(Token)
resolves any DI token — a CommandBus, a repository, a service — regardless of which transport your app uses.
When your app does serve HTTP, createTestClient (from @heximon/http/testing) drives real Requests
through the same generated fetch a production host serves — the full middleware chain, error
filters, and per-request Context, with no port and no socket.
Boot the app
Under vp test the heximon() plugin has already compiled your app, so createTestApp() reuses that
wiring directly — no plugins to list, no root to compute:
import { createTestApp } from "@heximon/testing";
import { UsersRepository } from "../src/users/users.repository";
import { expect, test } from "vitest";
test("createTestApp() resolves a DI token", async () => {
await using app = await createTestApp();
const repository = await app.get(UsersRepository);
expect(await repository.list()).toHaveLength(2);
});
await using is the right idiom: when the block exits it calls app[Symbol.asyncDispose](), which
shuts down the app (and removes the temp wiring directory if one was created). No afterAll is needed.
Each call boots a fresh, non-memoised app — onInit runs every time, in-memory databases start empty,
and no state bleeds between tests.
This works with no arguments because createTestApp() runs under
vp test, where the heximon() plugin is the active config and has already compiled your app at
buildStart — the harness reuses that in-memory wiring (no recompile, no temp directory), and root
defaults to the current directory, your project root under vp test.
For this to work, your project needs no vitest.config.ts: vp test then loads vite.config.ts, where
heximon() lives. (Vitest loads vitest.config.ts instead of vite.config.ts when both exist, which would
hide the plugin.) Configure your compiler plugins once there — or in the heximon.config.ts config file (see
Share the plugin list below) — and the test never
repeats them.
.ts
source through a Vite-powered module loader (.js specifiers the compiler rewrites need remapping to
.ts). Vitest is the supported path — a bare node --test, even with native type-stripping, is not.Pass options only for what the fast path can't infer: { root } when the test isn't run from the project
root (e.g. a monorepo fixture; defaults to process.cwd()), or { plugins } to force a standalone
recompile for an app with no heximon() config — the harness runs the compiler itself into a temp dir
(cleaned up on dispose) with exactly those plugins.
Same TestApp, purely a fallback; otherwise the
plugin set comes from your heximon.config.ts file.
Resolve DI tokens directly
app.get(Token) is the universal accessor. Any token your app's DI graph exposes — a service, a
repository, a bus — is reachable by class identity. Import the class from source; because Vitest dedupes
modules, the identity you import is the same one the compiler wired.
import { CommandBus, QueryBus } from "@heximon/cqrs";
import { createTestApp } from "@heximon/testing";
import { expect, test } from "vitest";
import { CreateUserCommand } from "../src/users/commands/create-user.command";
import { ListUsersQuery } from "../src/users/queries/list-users.query";
import { UserId } from "../src/users/user";
test("createTestApp() dispatches a command and reads it back through a query", async () => {
await using app = await createTestApp();
const commandBus = await app.get(CommandBus);
const queryBus = await app.get(QueryBus);
const id = UserId.generate();
await app.context.run(async () => {
await commandBus.execute(new CreateUserCommand(id, "Alice", "alice@example.com"));
const users = await queryBus.execute(new ListUsersQuery());
expect(users.some((user) => user.id === id)).toBe(true);
});
});
app.context.run(fn) opens the ambient Context frame. Any code that reads from an in-memory
database, drains a queue, or calls waitUntil on a Task needs this frame open — without it you
get a "context not available" error. HTTP requests from a test client open the frame automatically.
The same pattern works for event-driven apps (app.get(EventBus)), DDD apps (app.get(DomainEvents)),
or any service you've listed in a module's providers.
Add the HTTP client
HTTP is one opt-in layer, imported from @heximon/http/testing. Pass the TestApp to
createTestClient — it reads the routes and contract registrations off the app's wiring, so no
manual extraction is needed.
import { createTestClient } from "@heximon/http/testing";
import { createTestApp } from "@heximon/testing";
import { expect, test } from "vitest";
test("createTestApp() + createTestClient(app) drive GET /users", async () => {
await using app = await createTestApp();
const client = createTestClient(app);
const response = await client.request(new Request("http://localhost/users"));
expect(response.status).toBe(200);
expect(await response.json()).toHaveLength(2);
});
createTestClient runs the same generated fetch a production host serves — every middleware,
every error filter, body validation, and the per-request Context chain. Nothing about the HTTP layer
is mocked. If a middleware ordering or an error filter is wrong, the test sees the same Response a
browser would — there is no second, weakened code path to drift out of sync with production.
When you want the app to live across multiple requests in a test suite (sequential describe blocks,
beforeAll/afterAll), hold the TestApp as a variable and call app[Symbol.asyncDispose]() in
afterAll:
import { type TestClient, createTestClient } from "@heximon/http/testing";
import { type TestApp, createTestApp } from "@heximon/testing";
import { afterAll, beforeAll } from "vitest";
let app: TestApp;
let client: TestClient;
beforeAll(async () => {
app = await createTestApp();
client = createTestClient(app);
});
afterAll(() => app[Symbol.asyncDispose]());
Assert status, then body
Assert the HTTP status before the parsed body — the status is the contract, the body is the detail.
Against the seeded repository in
the ladder's HTTP validation example (L02),
GET /users returns the two users the repository seeds, and GET /users/:id resolves the typed :id
path parameter to one of them:
import { expect, test } from "vitest";
test("GET /users lists the seeded users", async () => {
const response = await client.request(new Request("http://localhost/users"));
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
expect(await response.json()).toEqual([
{ id: "1", name: "Alice", email: "alice@example.com" },
{ id: "2", name: "Bob", email: "bob@example.com" },
]);
});
test("GET /users/:id resolves the typed path parameter to a single user", async () => {
const response = await client.request(new Request("http://localhost/users/2"));
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ id: "2", name: "Bob", email: "bob@example.com" });
});
A small Request helper (json(path) / postJson(path, body) wrapping client.request(new Request(...)))
keeps call sites short when you exercise many routes — the examples below assume one.
Prove thrown errors map to problem+json
Because the real error formatter runs, a domain error thrown deep in the repository surfaces as the exact
problem+json it was wired to produce — you assert on the wire format, not on the exception.
The
repository throws UserNotFoundError extends NotFoundError; the controller has no custom ErrorFilter —
heximon-http's built-in ErrorFormatter renders it from the error's own status, so an unknown id comes back
as a 404 RFC 9457 document:
test("GET /users/:id maps a UserNotFoundError to a 404 RFC 9457 problem+json (built-in formatter)", async () => {
const response = await json("/users/999");
expect(response.status).toBe(404);
expect(response.headers.get("content-type")).toContain("application/problem+json");
const problem = (await response.json()) as Record<string, unknown>;
// The built-in formatter fills the RFC 9457 envelope from the thrown error: `status`/`title` come from the
// error's status (a `NotFoundError` → 404 "Not Found"), `instance` is the request path, and the human-readable
// message becomes `detail` (carrying the looked-up id).
expect(problem.status).toBe(404);
expect(problem.title).toBe("Not Found");
expect(problem.instance).toBe("/users/999");
expect(problem.detail).toContain("999");
});
The same repository throws EmailAlreadyInUseError extends ConflictError on a duplicate email — same
pattern, different status (409 "Conflict"), proven through the live chain with no custom filter.
Test contract-mode controllers
A contract-mode controller (implements Controller<SomeApi>) registers its routes at boot rather than
from the static table. When you build the client from the lower-level routes + createApp exports
directly (rather than from a TestApp), pass the generated contractRegistrations as a third argument
and the contract routes resolve exactly as they do in production:
import { createTestClient } from "@heximon/http/testing";
import { createApp } from "../.heximon/apps/main.wiring.js";
import { contractRegistrations } from "../.heximon/contracts.js";
import { routes } from "../.heximon/router.js";
const app = await createApp();
const client = createTestClient(routes, app, contractRegistrations);
const response = await client.request(new Request("http://test/api/products"));
expect(response.status).toBe(200);
createTestClient(app) (the one-argument form over a TestApp) reads contractRegistrations off the
app automatically — pass it explicitly only when you're driving the generated wiring by hand instead of
through createTestApp.
Swap real dependencies for fakes
An override replaces a DI token everywhere it is injected. createTestApp compiles in a dedicated
test wiring mode, so an override reaches every injection site of the token — same-module construction,
cross-module constructor inputs, dispatched handlers, and direct app.get(Token). The real provider is
never constructed when an override is active. The mental model is simple: the fake stands in for the real
provider at every site.
A controller is one such site — resolved at dispatch via app.get(UsersController) — which makes it an
easy seam to replace in a test. Two isolated apps, each with a different controller fake, see only their
own override:
import { createTestClient } from "@heximon/http/testing";
import { createTestApp } from "@heximon/testing";
import { expect, test } from "vitest";
import { UsersController } from "../src/users/users.controller";
test("two isolated apps with different controller overrides do not cross-talk", async () => {
class FakeUsersController {
constructor(private readonly roster: { id: string; name: string }[]) {}
public async list(): Promise<{ id: string; name: string }[]> {
return this.roster;
}
}
await using appA = await createTestApp({
overrides: [{ provide: UsersController, useValue: new FakeUsersController([{ id: "a1", name: "Anna" }]) }],
});
await using appB = await createTestApp({
overrides: [{ provide: UsersController, useValue: new FakeUsersController([{ id: "b1", name: "Bence" }]) }],
});
const clientA = createTestClient(appA);
const clientB = createTestClient(appB);
expect(await (await clientA.request(new Request("http://localhost/users"))).json()).toEqual([
{ id: "a1", name: "Anna" },
]);
expect(await (await clientB.request(new Request("http://localhost/users"))).json()).toEqual([
{ id: "b1", name: "Bence" },
]);
});
Each override is { provide: Token, useValue } (a ready-made instance), { provide: Token, useClass }
(a no-arg class the graph constructs once, shared across all consumers within that app), or
{ provide: Token, useFactory } (a factory given the request Context, built once and memoized — for a
fake that needs the ambient context, like a database). The token is the class you would inject — the same
class identity you import from source.
A same-module constructor dependency is reachable too. When Service(dep: Helper) and Helper live
in the same module, an override of Helper still reaches Service's constructor — test mode re-keys
every construction site through the override, not just the cross-module ones:
import { createTestApp } from "@heximon/testing";
import { FakeHelper, Helper } from "../src/helper";
import { Service } from "../src/service";
await using app = await createTestApp({
overrides: [{ provide: Helper, useClass: FakeHelper }],
});
const service = await app.get(Service);
expect(service.dep).toBeInstanceOf(FakeHelper);
One boundary is out of reach: a precompiled package's internals. An imported precompiled package (a library-mode boundary) is overridable
only at its declared seams — its requires and exports — never its internals, which were wired at that
package's own build time. A genuinely internal new EmailClient(config) where EmailClient is not a
DI token also has nothing for an override to key on; declare it as a token (or a constructor parameter)
and the override takes effect.
createTestApp returns a distinct, fully isolated handle each call, so a suite that boots several
apps in one process — one per tenant, or a fresh app per test — sees no cross-talk: the two apps above
have their own registries, and clientA/clientB see only their own controller override. Contract
routes are app-scoped the same way — keyed to the handle passed to createTestClient — so give each app
only its own contractRegistrations and a route registered on one app 404s on the other:
const clientA = createTestClient(routes, appA, [alphaRegistration]);
const clientB = createTestClient(routes, appB, [betaRegistration]);
expect((await clientA.request(new Request("http://test/alpha/ping"))).status).toBe(200);
expect((await clientA.request(new Request("http://test/beta/ping"))).status).toBe(404); // no leak
Isolate the database between tests
createTestApp produces a fresh DI graph per call — every provider is constructed anew, onInit
runs again, and no in-process state is shared between calls.
However, the DI graph and the database are
different things: the database in the Drizzle examples is configured as file::memory:?cache=shared (a
libsql connection string), and that connection is process-global — two createTestApp() calls within
the same test file share the same in-memory tables.
You can swap the database token itself with a useFactory override — the factory is given the request
Context, so a fake that needs the ambient context can be expressed, and the fake reaches every
constructor-injected consumer (a repository, a service):
import { createTestApp } from "@heximon/testing";
import { Database, FakeDatabase } from "../src/database";
import { WidgetRepository } from "../src/widget.repository";
await using app = await createTestApp({
overrides: [{ provide: Database, useFactory: () => new FakeDatabase() }],
});
// `WidgetRepository(database: Database)` is constructor-injected; the override reaches that constructor.
const repository = await app.get(WidgetRepository);
expect(repository.databaseLabel()).toBe("fake-db");
That swaps which database the app holds. The remaining process-global concern is the shared in-memory
connection when two apps in the same file use the real file::memory:?cache=shared database — they see
the same tables.
In practice this is rarely a problem. Vitest runs each test file in its own worker (projects mode), so different test files do not share the database. Within a single file, use one of these patterns:
- Fresh data per test. Generate unique ids (e.g.
uuid.v7()) and assert only on the data each test writes. Tests then coexist in the same tables without conflict. describe.sequential. Group tests that depend on a shared execution order into adescribe.sequentialblock and use onebeforeAll/afterAllpair for the whole block (as in the Drizzle example).- Separate files. If two suites truly must not see each other's rows, put them in separate test files and Vitest's per-file isolation takes care of the rest.
Test cross-service fan-out with auto-discovery
A service that opts into cross-service integration events declares a crossService block in its
heximon.config.ts — the scan vp build runs to discover which sibling services subscribe to which
events. createTestApp runs that same discovery scan against disk before compiling, so testing two
independently-deployed services together needs no hand-built topology — boot each service's root and let
the scan find the other:
export default defineHeximonConfig({
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
service: "orders",
crossService: { workspaceRoot: ".." },
});
workspaceRoot points the scan at the sibling-services root. Without it the scan walks up to the
nearest pnpm-workspace.yaml — in a nested services/* layout like this one that lands on the outer
workspace, not services/. Services sitting at the workspace's own top level can omit the knob
entirely (crossService: {}).
// Boot the SUBSCRIBER first so its consumer's poll loop is already draining when the producer publishes.
const billing = await createTestApp({
root: billingRoot,
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
eagerInit: true,
});
const orders = await createTestApp({
root: ordersRoot,
plugins: [new IntegrationEventsPlugin(), new HttpPlugin()],
eagerInit: true,
});
Placing an order on orders fans one row into billing's own queue — the exact production distributor
wiring the workspace scan discovered — and billing's compiler-emitted consumer drains it into its handler:
const response = await createTestClient(orders).request(
new Request("http://test/orders", { method: "POST" }),
);
const placed = (await response.json()) as { orderId: string };
// billing's compiler-emitted subscriber consumer drains its queue and dispatches to OnOrderPlaced.
await expect
.poll(() => log.received, { timeout: 5_000 })
.toEqual([{ orderId: placed.orderId }]);
Pass workspaceSubscribers only to override the disk scan — a unit test that wants a synthetic
topology without laying out a real workspace, or a topology you want pinned. Omit it and let each
service's own crossService declaration drive the discovery, exactly like a real vp build.
Surface eager construction errors
By default, providers are constructed lazily — the first app.get(Token) call triggers construction.
Set eagerInit: true to force upfront construction of every module and provider. Any misconfigured
dependency or broken onInit surfaces at setup time rather than buried inside a later assertion:
test("app boots with no construction errors", async () => {
await using app = await createTestApp({ eagerInit: true });
expect(app).toBeDefined();
});
eagerInit() is also exposed as a method on the TestApp if you need to call it manually after boot.
Share the plugin list with heximon.config.ts
createTestApp's plugins option accepts the same CompilerPlugin[] you pass to the heximon()
Vite plugin. To declare the list once, put it in a heximon.config.ts file at your project
root. All three hosts — the Vite plugin, the Nitro module, and the Nuxt module — auto-load it, and
createTestApp auto-loads it too. No explicit import or reference is needed in the host config.
import { defineHeximonConfig } from "@heximon/build";
import { CqrsPlugin } from "@heximon/cqrs/compiler";
import { HttpPlugin } from "@heximon/http/compiler";
export default defineHeximonConfig({ plugins: [new HttpPlugin(), new CqrsPlugin()] });
The vite.config.ts from Boot the app above needs no change — heximon() auto-loads this
file from the project root, and createTestApp() reads the same file, so neither the host config nor
the test repeats the plugin list.
The heximon.config.ts file is the single source of truth for the plugin set — the Vite plugin,
the Nitro module, the Nuxt module, and createTestApp all read it identically, so there's nothing to
reconcile between layers.
Host-intrinsic plugins (like NitroPlugin, which requires HTTP) are added on
top and deduped by class identity. An explicit plugins option passed to createTestApp always wins
over the config file; with neither present, the plugin set is [] — transport-neutral core, HTTP never
assumed. So list the plugins your app needs once, in heximon.config.ts (a CQRS app → new CqrsPlugin(),
an HTTP app → new HttpPlugin()), rather than repeating them in every test.
The shared loader is ConfigLoader.load(rootDir), exported from @heximon/compiler/project. It is backed
by c12 (which uses jiti), so it loads the TypeScript config file at build time and under test runners
alike. It recognises heximon.config.{ts,mjs,js,json,...} (plus c12's .local/env layers) and
returns undefined when no config file is present.
API reference
createTestApp(options?)
| Option | Type | Default | Description |
|---|---|---|---|
root | string | process.cwd() | Project root containing src/. |
plugins | CompilerPlugin[] | from config file | Compiler plugins for the standalone compile. Passing this forces the standalone compile; omit it to use the fast path + heximon.config.ts. |
overrides | ProviderOverride[] | [] | Provider fakes (replace a DI token at every injection site) for the lifetime of this app. Each is { provide, useValue }, { provide, useClass }, or { provide, useFactory }. |
bindings | Record<string, unknown> | {} | Runtime bindings seeded into the app — the host seam (e.g. { InternalClient: new H3FetchClient(...) } for an app whose controllers call their own contract routes; see example gap/openapi-mcp). Distinct from overrides: bindings are ambient values keyed by name, not DI token replacements. |
eagerInit | boolean | false | Construct every provider at setup time. |
workspaceSubscribers | WorkspaceSubscriptionTopology | auto-discovered | The cross-service integration-event topology. When the app declares crossService in heximon.config.ts, createTestApp auto-discovers sibling services + subscriptions from disk — the same scan vp build runs. Supply this only to override that scan with a synthetic topology. |
platform | PlatformStrategy | from config file | The deploy strategy to compile with (the in-process equivalent of heximon.config.ts's platform line), so a test exercises the same capability legs (OTel, durable timers, workflow engine) the app deploys with. |
Returns TestApp = AppHandle & AsyncDisposable & { context, eagerInit(), routes?, contractRegistrations? }.
TestApp members
| Member | Description |
|---|---|
get(Token) | Resolve a DI token. Returns the same instance the generated wiring constructed. |
context.run(fn) | Open the ambient Context frame for code that needs it (repositories, buses, tasks). |
context.drain(options?) | Wait until every in-flight Context.run frame — and any waitUntil work it deferred — has settled. Resolves immediately when nothing is in flight; an optional { timeoutMs } caps the wait. Useful in a test that fires a request and needs its background waitUntil work done before asserting, without wiring the generated graceful-shutdown sequence yourself. |
eagerInit() | Force upfront construction of every module and provider. |
routes | Opaque HTTP route definitions (present only when HttpPlugin was active). Consumed by createTestClient. |
contractRegistrations | Opaque HTTP contract registrations (present only when HttpPlugin was active). Consumed by createTestClient. |
[Symbol.asyncDispose]() | Shut down the app and remove the temp wiring directory. Called automatically by await using. |
shutdown() | Shut down the app explicitly (same as the AppHandle method). |
createTestClient(app) / createTestClient(routes, app, contractRegistrations?)
| Form | When to use |
|---|---|
createTestClient(app) | The default — pass a TestApp and it reads routes + contractRegistrations off it automatically. |
createTestClient(routes, app, contractRegistrations?) | Driving the generated wiring directly (routes from router.js, createApp from the app's wiring file) without createTestApp — pass contractRegistrations from contracts.js only when the app serves contract-mode controllers. |
createTestClient assembles the server once per client, then hands you request(req). Reuse the one
client across every request in a test — building a client per request rebuilds the route table for no
reason.
See also
- Testing — the decision ladder: unit-test a provider with fakes, dispatch over the real bus, or boot the full app — start there when you're deciding which shape a new test needs.
- Controllers — the
Controller,Middleware, andHttpActionsurface the transport exercises. - CQRS — the
CommandBus/QueryBusyou dispatch through in transport-neutral tests. - Modules & DI — how
providersandexportsdefine whatapp.get(Token)can resolve. - Lifecycle —
Context.drain()in full, and the generated graceful-shutdown sequence it backs in production. - Integration events — the
crossServiceconfig block, the outbox, and the four cross-service fan-out legs a workspace-topology test exercises. - HTTP validation — two feature modules, middleware, validation, and the built-in error formatter, with a test that drives every route in-process and proves the
404/409problem+json mapping. - DDD with Drizzle — a full compile → DI → Drizzle/libsql → HTTP test using
createTestAppandcreateTestClient(app), including anapp.context.run(...)block that proves optimistic concurrency raisesConcurrencyError. - Cross-service fan-out — two independently-rooted services (
orders+billing) whosecreateTestAppcalls auto-discover the workspace topology from each service's owncrossServicedeclaration.
Testing a Heximon App
Unit-test providers and handlers as plain classes with constructor-injected fakes, then reach for createTestApp and createTestClient when you need the real compiled wiring — routes, middleware, validation, error filters, and bus dispatch.
Logging & Observability
Logger, child fields, Logger.configure, LoggerReporter pretty and json, log levels, HEXIMON_DEBUG, shipping logs to drains, opt-in wide events, and the startup summary lines.