# Testing

`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.

```ts
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

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

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:

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

### 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](/guides/reacting-to-events/#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:

```ts
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.

## Time

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](https://github.com/bounda-dev/bounda/blob/main/examples/storefront/tests/storefront.test.ts),
where an order carries items:

```ts
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:

```ts
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](/guides/dead-letters/). 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

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:

```ts
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.

```ts
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](https://node.testcontainers.org/) 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:

```ts
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 }),
});
```

See [testing on Cloudflare](/adapters/cloudflare/#testing).

## 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:

```ts
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:

```ts
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:

```ts
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

`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:

```ts
expect((await app.getLag()).maxLag).toBe(0);
```

## 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](/guides/changing-events/), not a test rewrite.