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.
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.
runUntilIdle is the whole trick
Section titled “runUntilIdle is the whole trick”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.
Asserting a rejection
Section titled “Asserting a rejection”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:
await expect( app.commands.placeOrder({ orderId, customerId: "ada", total: 1 }),).rejects.toMatchObject({ rejected: "AlreadyPlaced" });Rejections inside reactions
Section titled “Rejections inside reactions”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:
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:
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:
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.
Against a real database
Section titled “Against a real database”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:
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.
import { postgresql } from "@bounda-dev/postgresql";import { PostgreSqlContainer } from "@testcontainers/postgresql";
const container = await new PostgreSqlContainer("postgres:17").start();const { app } = await createTestApp({ registry, adapter: postgresql({ url: container.getConnectionUri(), schema: "test" }),});Testcontainers starts a real PostgreSQL; use it when a query relies on something only PostgreSQL does. A schema per test file keeps them apart.
The tests run inside workerd through @cloudflare/vitest-plugin. createTestApp runs the
domain as anywhere else, and SELF.fetch from cloudflare:test reaches the Worker and the
real Durable Object and its SQLite:
import { SELF } from "cloudflare:test";
const response = await SELF.fetch("http://example.com/commands/placeOrder", { method: "POST", headers: { "content-type": "application/json", "x-bounda-tenant": "test" }, body: JSON.stringify({ orderId: crypto.randomUUID(), customerId: "ada", total: 42 }),});Doubles
Section titled “Doubles”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:
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:
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:
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.
Nothing left behind
Section titled “Nothing left behind”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:
expect((await app.getLag()).maxLag).toBe(0);What is worth testing
Section titled “What is worth testing”- 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.