Skip to content

    Guides07

    Testing

    An app in memory, a clock that only moves when told to, and assertions that do not flake.

    createTestApp builds an app for tests: in-memory storage, a clock that only moves when you move it, and sequential ids (id-1, id-2, …). Nothing is shared between tests and nothing depends on wall-clock time, so the same assertions hold on every run.

    TypeScript
    import { createTestApp } from "@bounda-dev/core/testing";
    import { describe, expect, it } from "vitest";
    import { registry } from "../.bounda/registry.ts";
    const orderId = "018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01";
    describe("orders", () => {
    it("places an order and lists it for the customer", async () => {
    const { app } = await createTestApp({ registry });
    await app.commands.placeOrder({ orderId, customerId: "ada", total: 42 });
    await app.runUntilIdle();
    expect(await app.queries.listOrders({ customerId: "ada" })).toEqual({
    orders: [{ orderId, customerId: "ada", total: 42, placedAt: expect.any(Date) }],
    total: 42,
    });
    await expect(
    app.commands.placeOrder({ orderId, customerId: "ada", total: 1 }),
    ).rejects.toMatchObject({ rejected: "AlreadyPlaced" });
    await app.stop();
    });
    });

    That is the shape of every test: dispatch a command, await app.runUntilIdle(), assert through a query. This one is the test a new project comes with.

    A command returns as soon as its events are stored. Projections, policies and processes run afterwards, so asserting straight after the command would race them. runUntilIdle() runs dispatcher passes and due scheduled commands until nothing moves, which is the point where every consequence of what you dispatched has happened.

    It works in every runtime role, and it is the reason tests need no timers, no polling and no await sleep(50). It resolves to { idle: true, rejections }; pass { maxPasses } to stop earlier, which is how a test checks that a chain of reactions takes more than one round.

    Call app.stop() when the test ends: it waits for passes in flight and closes storage.

    A command its handler rejects throws a DomainError to app.commands, with the code in rejected. Assert on the code, not on the message, so the wording stays free to change:

    TypeScript
    await expect(
    app.commands.placeOrder({ orderId, customerId: "ada", total: 1 }),
    ).rejects.toMatchObject({ rejected: "AlreadyPlaced" });

    The commands a policy, a process or the scheduler dispatches have no caller to throw to: a rejection there is an answer the handler may ignore, and the run goes on (see what a command answers). runUntilIdle() returns the ones that happened while it ran, in order, each with the command’s type, the code in rejected, its message and the aggregate it was for. A test asserts the ones it expects, so an unexpected one does not pass in silence:

    TypeScript
    await app.commands.settlePayment({ paymentId });
    await app.commands.cancelOrder({ orderId, reason: "changed my mind" });
    const { rejections } = await app.runUntilIdle();
    expect(rejections).toMatchObject([{ type: "MarkOrderPaid", rejected: "NotOpen" }]);

    A run that is retried counts its rejections only from the attempt that commits.

    The clock starts at 2026-01-01T00:00:00Z and stays there until you advance it. A scheduled command becomes due, a delayed policy runs and a process deadline comes due because the clock moved — never because the test waited.

    The examples from here on come from the storefront’s test, where an order carries items:

    TypeScript
    const ORDER = "018f6a5e-4c3c-7c1e-9d4b-0b2c4a1d8e01";
    const items = [
    { productId: "keyboard", quantity: 1, price: 120 },
    { productId: "cable", quantity: 2, price: 9.5 },
    ];
    const HOUR = 3_600_000;
    it("reminds the customer a day later only while the order is still placed", async () => {
    const { app, clock } = await createTestApp({ registry });
    await app.commands.placeOrder({ orderId: ORDER, customerId: "ada", items });
    await app.runUntilIdle();
    clock.advance(24 * HOUR);
    await app.runUntilIdle();
    expect((await app.queries.getOrderSummary({ orderId: ORDER }))?.reminderSent).toBe(true);
    await app.stop();
    });

    The same two lines — advance, then process — test a process that gives up:

    TypeScript
    clock.advance(72 * HOUR);
    await app.runUntilIdle();
    expect(await app.queries.getOrderSummary({ orderId: ORDER })).toMatchObject({
    status: "cancelled",
    cancelledAt: expect.any(Date),
    });

    One advance is enough however many deadlines it passes: runUntilIdle() runs a daily reminder for every day the clock skipped, in order, before it resolves.

    Retries are the one wait you never advance for. A policy, a process handler or a scheduled command that fails with an error worth retrying waits for its back-off, and runUntilIdle() moves the clock to it itself: by the time it resolves, every such failure has been retried until it went through or gave up as a dead letter. A test of a provider that fails once dispatches, runs until idle and asserts the second call; clock.now() says how far the retries took it. It never moves the clock past the last retry: what is scheduled before it runs on the way, in order, and what is scheduled after it waits for your advance. A retry that no longer waits, because its process ended or its deadline moved, moves nothing. Await runUntilIdle() before dispatching anything else: moving the clock also runs out the time of a command or handler that is still running on the same app.

    A test that builds a process state by hand, say to call a handler on its own, needs its moments typed as Instant, which a plain string is not: asInstant from @bounda-dev/core makes one from a date or an ISO 8601 string in UTC, nextReminder: asInstant("2026-01-02T00:00:00.000Z").

    Pass now to start somewhere else: createTestApp({ registry, now: new Date("2026-06-01") }).

    The clock also owns every wait the runtime makes. A handler timeout fires when the clock passes it, not after real milliseconds: a command handler that never finishes holds await app.commands.x() until you advance the clock past runtime.commands.timeout, and a policy or process handler holds runUntilIdle() until it passes runtime.policies.timeout or runtime.processes.handlerTimeout. The background loops that app.start() arms wait on it too: in a test they run only when you advance the clock, and clock.pending() says how many waits are armed, which is none once app.stop() has resolved.

    The in-memory adapter is the default, not a requirement. Point the test at a real engine to exercise the SQL the adapter generates, the column types it picks and the read-model schema it creates:

    TypeScript
    import { sqlite } from "@bounda-dev/sqlite";
    const { app } = await createTestApp({ registry, adapter: sqlite({ memory: true }) });

    An in-memory SQLite database is created per app and thrown away with it, so tests stay isolated and fast.

    A test passes what each port of each aggregate or read model receives, by module and port. Pass a double written in the test, so each test owns what it records and tests running in parallel share nothing:

    TypeScript
    import type { NotifierArgs } from "../app/domain/order/notifier.ts";
    const sent: NotifierArgs[] = [];
    const { app } = await createTestApp({
    registry,
    adapter: sqlite({ memory: true }),
    ports: { order: { notifier: async (confirmation) => void sent.push(confirmation) } },
    });

    A policy runs after the command, so let it run, then assert on what the double recorded:

    TypeScript
    await app.commands.placeOrder({ orderId: ORDER, customerId: "ada", items });
    await app.runUntilIdle();
    expect(sent).toEqual([
    { orderId: ORDER, customerId: "ada", total: 139, idempotencyKey: expect.any(String) },
    ]);

    The same way, a provider that rejects or hangs is a double that throws or never resolves, with no file for each scenario. The handlers receive a double as it is, and app.stop() never closes it: it belongs to the test.

    A port can also take the file name of one of its implementations. An implementation that exports create is then built for that test app, with the test’s clock and logger, and closed by app.stop(). Its env is what the test passes, never the test’s process.env, and an empty object otherwise:

    TypeScript
    const { app } = await createTestApp({
    registry,
    ports: { order: { inventory: "http" } },
    env: { INVENTORY_URL: "http://localhost:8080" },
    });

    An implementation that reads tenant receives what the test passes as tenant, the name a Durable Object would have, and none without it: createTestApp({ registry, ports, tenant: "acme" }).

    On Cloudflare env is Cloudflare.Env, whose bindings an empty object does not have, so createTestApp requires it there as soon as an implementation exports create; inside workerd, pass env from cloudflare:workers.

    createTestApp does not read bounda.config.ts, and a port the test leaves out has no implementation, even when it has only one, so a test never calls a real provider it did not ask for. A handler that reads that port throws a ConfigurationError that names the aggregate and the port and says what to pass. A command rejects with it, a policy or a process does not retry it, and from then on every app.runUntilIdle() throws it, so the test fails with that message instead of an assertion further down. Handlers that never read the port run as usual, but reading every port at once, as ({ notifier, ...rest }) does, reads that one too.

    app.getLag() reports how far each subscriber is behind the head of the global stream. Asserting it is zero proves a test left no work pending, which catches a policy that quietly stopped reacting:

    TypeScript
    expect((await app.getLag()).maxLag).toBe(0);
    • The rules, through commands: what is accepted, what is rejected and with which code, and what the aggregate does on the second attempt.
    • The consequences, through queries: the read model after the events, including fields a projection fills from more than one event.
    • Time, by advancing the clock: reminders that go out, reminders that no longer apply, and processes that run out of time.
    • Commands in, queries out. An app test drives the app as a caller does and checks what a caller can see. Projections and process state are how the app gets there; asserting on them turns a refactor into a test rewrite. Events are not in that list: they are the aggregate’s contract and stay in the event store for good. An app test reaches them through what they produce, a row in a query or a rule a later command meets, and a change to their shape is an upcaster, not a test rewrite.