Heximon Logo
Essentials

Test app harness

createTestApp, TestApp, TestAppOptions, createTestClient, eagerInit, context.run, provider overrides, database isolation, contract-mode registration, multi-app isolation, heximon.config.ts plugin precedence

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:

test/users.test.ts
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.

Heximon is a Vite-based framework — run tests under Vitest. The harness imports your app's own .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.

test/users.test.ts
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.

test/users.test.ts
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:

test/app.e2e.test.ts
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:

users.test.ts
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:

users.test.ts
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:

products.test.ts
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:

test/multi-app-isolation.test.ts
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:

test/service.test.ts
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:

contracts-isolation.test.ts
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):

test/database-swap.test.ts
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 a describe.sequential block and use one beforeAll/afterAll pair 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:

services/orders/heximon.config.ts
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: {}).

test/cross-service.e2e.test.ts
// 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:

test/cross-service.e2e.test.ts
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/sanity.test.ts
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.

heximon.config.ts
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?)

OptionTypeDefaultDescription
rootstringprocess.cwd()Project root containing src/.
pluginsCompilerPlugin[]from config fileCompiler plugins for the standalone compile. Passing this forces the standalone compile; omit it to use the fast path + heximon.config.ts.
overridesProviderOverride[][]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 }.
bindingsRecord<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.
eagerInitbooleanfalseConstruct every provider at setup time.
workspaceSubscribersWorkspaceSubscriptionTopologyauto-discoveredThe 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.
platformPlatformStrategyfrom config fileThe 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

MemberDescription
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.
routesOpaque HTTP route definitions (present only when HttpPlugin was active). Consumed by createTestClient.
contractRegistrationsOpaque 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?)

FormWhen 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, and HttpAction surface the transport exercises.
  • CQRS — the CommandBus / QueryBus you dispatch through in transport-neutral tests.
  • Modules & DI — how providers and exports define what app.get(Token) can resolve.
  • LifecycleContext.drain() in full, and the generated graceful-shutdown sequence it backs in production.
  • Integration events — the crossService config 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/409 problem+json mapping.
  • DDD with Drizzle — a full compile → DI → Drizzle/libsql → HTTP test using createTestApp and createTestClient(app), including an app.context.run(...) block that proves optimistic concurrency raises ConcurrencyError.
  • Cross-service fan-out — two independently-rooted services (orders + billing) whose createTestApp calls auto-discover the workspace topology from each service's own crossService declaration.
Copyright © 2026