Heximon Logo
Essentials

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.

A dependency in Heximon is a constructor parameter, so a fake is just an argument — there's no runtime container to stand up, no token registry to mock, and no decorator metadata to fight. That single fact is why most of a Heximon app tests like plain TypeScript: new the class with fakes, call a method, assert.

test/users.test.ts
// The L01-minimal repository — the smallest real shape (starters/minimal ships the same file).
import { expect, test } from "vitest";
import { UsersRepository } from "../src/users/users.repository";

test("UsersRepository lists seeded users", async () => {
  const repo = new UsersRepository();
  const users = await repo.list();
  expect(users).toHaveLength(2);
  expect(users[0]?.name).toBe("Alice");
});

test("UsersRepository finds a user by id", async () => {
  const repo = new UsersRepository();
  expect(await repo.findById("2")).toEqual({ id: "2", name: "Bob" });
  expect(await repo.findById("999")).toBeUndefined();
});

No framework is involved because none is needed — there are no hidden dependencies to reconstruct. Reach past a plain unit test only when the thing you want to prove genuinely needs more:

ShapeWhat it provesHow
UnitOne provider or handler in isolationnew Service(fakeDep), call a method, assert
App harnessAny provider over the real compiled wiringcreateTestApp(...), then app.get(Token)
HTTP in-processThe full route chain over a real RequestcreateTestApp + createTestClient(app)
Self-clientURL build, body serialization, response validationnew SomeApi(ClientTransport.mock({...})), call .client

A repository also owns domain rules worth proving directly — a duplicate email should raise a domain error, not a generic throw:

test/users.repository.test.ts
import { describe, expect, test } from "vitest";
import { EmailAlreadyInUseError } from "../src/users/user-errors";
import { UsersRepository } from "../src/users/users.repository";

describe("UsersRepository", () => {
  test("rejects a duplicate email with a domain error", async () => {
    const repository = new UsersRepository();

    await expect(repository.create({ name: "Another Alice", email: "alice@example.com" })).rejects.toBeInstanceOf(
      EmailAlreadyInUseError,
    );
  });
});

Inject fakes through the constructor

When the class under test has collaborators, pass fakes straight into the constructor. A fake is any object that satisfies the parameter's type. A CQRS handler is exactly this shape: implements CommandHandler<...> adds the message binding, but the constructor is still the dependency declaration, and handle is still a plain method you can call:

test/create-user.handler.test.ts
import { expect, test, vi } from "vitest";
import { CreateUserCommand } from "../src/users/commands/create-user.command";
import { CreateUserCommandHandler } from "../src/users/commands/create-user.handler";
import { UserId } from "../src/users/user";

// The constructor IS the dependency declaration — pass a fake store directly, no DI involved.
test("handle saves a new user at version 1", () => {
  const store = { save: vi.fn(), findById: vi.fn() };
  const handler = new CreateUserCommandHandler(store as never);

  handler.handle(new CreateUserCommand(UserId.from("u-1"), "Alice", "alice@example.com"));

  expect(store.save).toHaveBeenCalledWith(expect.objectContaining({ name: "Alice", version: 1 }));
});

You only ever fake what the constructor declares — there's no token resolution and no module wiring to account for, so testing a handler is no harder than testing a function. Error paths test the same way: drive the fake into the failing state and assert the throw.

test/update-user.handler.test.ts
import { NotFoundError } from "@heximon/runtime/errors";
import { expect, test, vi } from "vitest";
import { UpdateUserCommand } from "../src/users/commands/update-user.command";
import { UpdateUserCommandHandler } from "../src/users/commands/update-user.handler";
import { UserId } from "../src/users/user";

// Error paths test the same way: drive the fake into the failing state and assert the throw.
test("handle throws NotFoundError for an unknown id", () => {
  const store = { findById: vi.fn().mockReturnValue(undefined), save: vi.fn() };
  const handler = new UpdateUserCommandHandler(store as never);

  expect(() => handler.handle(new UpdateUserCommand(UserId.from("missing"), { name: "X" }))).toThrow(NotFoundError);
  expect(store.save).not.toHaveBeenCalled();
});

This proves the error path at the method level. Proving the bus retries a ConcurrencyError — the 3-attempt policy baked into the wiring — needs the real compiled dispatch, covered below.

Drive routes with the in-process client

When you want to test what a request actually does — the typed route, the middleware chain, body validation, the error filters, the per-request context — boot the compiled app and send it real Requests. createTestClient runs the same generated fetch a production host serves, so the round-trip exercises the real wiring with no port and no network.

The recommended way to boot the app in a test is createTestApp from @heximon/testing. It compiles and boots a fresh, isolated app in one call — no manual .heximon/ imports, no temp-dir cleanup:

test/users.test.ts
import { createTestClient } from "@heximon/http/testing";
import { createTestApp } from "@heximon/testing";
import { expect, test } from "vitest";
import { UsersRepository } from "../src/users/users.repository";

test("createTestApp() resolves a DI token", async () => {
  await using app = await createTestApp();

  const repository = await app.get(UsersRepository);

  expect(await repository.list()).toHaveLength(2);
});

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);
});

A POST is the same call with a JSON body, and an invalid body proves the validation filter runs before your handler — the 400 comes from the boundary, not from code you wrote:

test/users.test.ts
import { type TestClient, createTestClient } from "@heximon/http/testing";
import { type TestApp, createTestApp } from "@heximon/testing";
import { afterAll, beforeAll, expect, test } from "vitest";

let app: TestApp;
let client: TestClient;

beforeAll(async () => {
  app = await createTestApp();
  client = createTestClient(app);
});

afterAll(() => app[Symbol.asyncDispose]());

test("POST /users rejects an invalid body with a 4xx (built-in validation filter)", async () => {
  const response = await client.request(
    new Request("http://localhost/users", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ name: "", email: "not-an-email" }), // fails the create schema
    }),
  );

  expect(response.status).toBeGreaterThanOrEqual(400);
  expect(response.status).toBeLessThan(500);
});

Reuse one client across a test's requests — createTestClient(app) is cheap; the boot cost is in createTestApp. See Test app harness for the full reference: provider overrides, database isolation, contract-mode registration, multi-app isolation, and configuring plugins via heximon.config.ts.

Dispatch commands and queries over the real bus

A command or query handler is wired to its bus at compile time, so the most faithful way to test one is to dispatch through the real bus. Boot the app, get the bus by its class token, and execute a message — exactly what a controller does. Because commands return void, mint the id up front and read the new state back with a query.

test/cqrs.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);
  });
});

Dispatching through the bus exercises the handler, its dependencies, and the automatic ConcurrencyError retry together — and the retry, living in the wiring rather than your handler, can only be observed this way. Direct dispatch outside an HTTP request requires app.context.run(...) to open the ambient Context frame; an HTTP request opens it automatically.

Assert side effects through a shared sink

CQRS and event handlers route by class identity, so spying on a handler instance would miss whether the wiring actually reached it. Assert the observable effect instead: have the handler write to a small shared provider, dispatch the message over the real bus, then read the provider back off the same app handle. There's one instance per token, so the provider you assert is the one the handler wrote to.

The CQRS example uses an AuditLog provider that an interceptor records into around every command:

audit.test.ts
import { CommandBus } from "@heximon/cqrs";
import { createTestApp } from "@heximon/testing";
import { AuditLog } from "../src/users/audit-log";
import { CreateUserCommand } from "../src/users/commands/create-user.command";
import { UserId } from "../src/users/user";

test("the app-wide interceptor records a before+after audit entry around each command", async () => {
  await using app = await createTestApp();
  const commandBus = await app.get(CommandBus);
  const auditLog = await app.get(AuditLog); // the SAME instance the interceptor records into

  auditLog.clear();

  // Direct bus dispatch needs an open Context frame — HTTP requests open it for you.
  await app.context.run(async () => {
    await commandBus.execute(
      new CreateUserCommand(UserId.from("u-audit"), "Erin", "erin@example.com"),
    );
  });

  // The around-chain interceptor ran for real — proven by the sink, not a spy.
  expect(auditLog.entries()).toEqual([
    { command: "CreateUserCommand", phase: "before" },
    { command: "CreateUserCommand", phase: "after" },
  ]);
});

Import the command, query, and provider classes statically (never via await import(...)). The bus dispatches by class identity, so a statically imported class resolves to the same src/ module the compiler wired — their identities line up where a dynamic import's would not.

Swap a real dependency for a fake with overrides

An override substitutes a fake for a real provider, keyed by class identity. It replaces the DI token everywhere it is injected — same-module construction, cross-module constructor inputs, dispatched handlers, and direct app.get(Token). createTestApp compiles in a dedicated test wiring mode that gives the override full reach, so you can reason about it simply: the fake stands in for the real provider at every injection site.

What overrides can reach: any DI token — a service, a bus, a controller, or a token another provider takes as a constructor argument. A controller is resolved at dispatch time, so overriding it means the fake's methods handle every route the controller owns. The pattern that follows swaps UsersController for a fake whose list returns a fixed roster, proving the override takes effect at dispatch time:

test/overrides.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";
import { type User } from "../src/users/users.repository";

test("overriding a controller makes its dispatched route serve the fake", async () => {
  // A controller fake whose `list` returns a fixed roster — substituted via `createTestApp({ overrides })`.
  class FakeUsersController {
    public async list(): Promise<User[]> {
      return [{ id: "a1", name: "Anna", email: "anna@example.com" }];
    }
  }

  await using app = await createTestApp({
    overrides: [{ provide: UsersController, useValue: new FakeUsersController() as unknown as UsersController }],
  });
  const client = createTestClient(app);

  const response = await client.request(new Request("http://localhost/users"));

  // The controller token is resolved at dispatch via app.get — the override-reachable seam — so the route
  // serves only the fake's roster.
  expect(await response.json()).toEqual([{ id: "a1", name: "Anna", email: "anna@example.com" }]);
});

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. Your app's own source has no such limit: every token at every site is overridable. To override a package's internals, test that package in its own suite.

Each override is { provide: Token, useValue } (a ready-made instance), { provide: Token, useClass } (a no-arg class the graph constructs once and memoizes), 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).

Prefer fakes over mocks for anything with real behavior — an in-memory store, a fixed clock — since they read clearly in the assertion and don't break when an unrelated call is added; reach for vi.fn() when you specifically need to assert how a collaborator was called. The full reference — every override form, database isolation, and cross-service topology — is on Test app harness.

Test a contract self-client with ClientTransport.mock

A contract is also a typed client — new SomeApi(transport) — and you can test the client-side logic (URL assembly, body serialization, argument marshalling, response validation) without a running server by wiring it to a ClientTransport.mock. Import ClientTransport.mock from @heximon/client and pass a "METHOD /path"ClientResponse map:

products-client.test.ts
import { Contract, Route } from "@heximon/contract";
import { SchemaObject } from "@heximon/schema";
import { ClientTransport } from "@heximon/client";
import * as v from "valibot";
import { expect, test } from "vitest";

class SkuParams extends SchemaObject({ sku: v.string() }) {}
class NewProduct extends SchemaObject({ name: v.string() }) {}
class Product extends SchemaObject({ sku: v.string(), name: v.string() }) {}
class NotFound extends SchemaObject({ reason: v.string() }) {}

class ProductsApi extends Contract({
  prefix: "/api/products",
  routes: {
    find: Route.get("/:sku").pathParams(SkuParams).responses({ 200: Product, 404: NotFound }),
    create: Route.post("/").body(NewProduct).responses({ 201: Product }),
  },
}) {}

test("a 2xx caller round-trips through the canned map and returns the success body", async () => {
  const api = new ProductsApi(
    ClientTransport.mock({
      "GET /api/products/abc": { status: 200, headers: {}, body: { sku: "abc", name: "Widget" } },
    }),
  );

  const product = await api.client.find({ params: { sku: "abc" } });

  expect(product).toEqual({ sku: "abc", name: "Widget" });
});

The mock key is "METHOD /path" — the contract's prefix is already folded into the path by the client, so the key includes the full path. api.client.find(...) (the .client caller) throws a ContractError on a non-2xx; api.raw(key, args) never throws and returns the full discriminated union instead — use it when you specifically need to assert error-status handling:

products-client.test.ts
test("raw() returns a non-2xx result without throwing", async () => {
  const api = new ProductsApi(
    ClientTransport.mock({
      "GET /api/products/gone": { status: 404, headers: {}, body: { reason: "gone" } },
    }),
  );

  const result = await api.raw("find", { params: { sku: "gone" } });

  expect(result.status).toBe(404);
  if (result.status === 404) {
    expect(result.body).toEqual({ reason: "gone" });
  }
});

Run the tests

Tests run on Vitest through the vp test task. Group cases with describe, write each with test, and assert HTTP status before body so a failing status explains the response you got.

pnpm test

pnpm test resolves the @heximon/* packages from source, so you never rebuild a class before testing it. When using createTestApp, the app is compiled fresh for each test run — no dev server prerequisite, no stale .heximon/ directory.

Next steps

  • Test app harnesscreateTestApp and createTestClient in full: every override form, database isolation, contract-mode registration, multi-app isolation, eager init, and configuring plugins via heximon.config.ts.
  • Controllers — the routes, middleware, and error filters your HTTP tests exercise.
  • CQRS — the CommandBus / QueryBus you dispatch through, and the retry a bus test surfaces.
  • Modulesproviders and the import graph that decide which tokens a provider can inject.
  • L02 — HTTP validation — two feature modules with an in-process end-to-end test that boots the real server and proves routing, validation, and the error filters that map domain errors to RFC 9457 responses.
  • L05 — CQRS commands & queries — bus-dispatch tests that drive CommandBus / QueryBus directly and prove the 3-attempt concurrency retry.
  • Example — Flagship — a full register → login → create → complete flow tested end-to-end, with a fake email transport capturing the domain-event notification.
Copyright © 2026