# Testing your app

Rindle's determinism rules make an app unusually testable: a mutator is a pure generator you can run against a `Map`, the wasm engine applies predictions synchronously so a store-level test needs no awaits, and queries are values you can assert on as ASTs or materialize against a seeded store. The ladder from pure unit tests to a real two-client e2e.

The parts of a Rindle app you wrote — mutator bodies, named queries, fragments — are deliberately
deterministic values. A mutator can't read clocks or randomness (they arrive as args), and a query
is data. That makes the testing story a ladder, where each rung needs strictly more machinery and
buys strictly more realism. Most of your tests belong on the bottom two rungs. Everything below
uses `node --test` + `node:assert/strict` — no framework required.

## Rung 1: mutator bodies as op logs — no engine at all

A mutator body `yield`s logical ops. It never touches a database. So the cheapest test invokes the
generator and drives it with `driveMutationSync` against whatever "executor" you hand it — an array
to capture the ops, a `Map` to answer its reads:

```ts
// test/mutators.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { driveMutationSync, isoTx } from "@rindle/client";
import type { KeyedRow, MutationOp } from "@rindle/client";
import { mutators } from "../shared/app-def.ts";

test("createIssue normalizes the title and stamps the owner", () => {
  const ops: MutationOp[] = [];
  const gen = mutators.createIssue(
    isoTx,
    { id: "i1", title: "  Ship it  ", status: "todo", priority: "high", createdAt: 1000 },
    { user: "alice" },
  );
  driveMutationSync(gen, {
    apply: (op) => ops.push(op),
    read: () => undefined,          // answer `yield tx.row(...)` reads
    query: () => [],                // answer `yield tx.query(...)` reads
  });

  const insert = ops.find((op) => op.kind === "insert" && op.table === "issue");
  assert.ok(insert);
  assert.equal(insert.row.title, "Ship it");     // the shared normalization ran
  assert.equal(insert.row.ownerId, "alice");     // ctx.user, never a client-supplied arg
});
```

For a body with reads, back `read`/`query` with a `Map` keyed `` `${table}:${pk}` `` and assert the
op log branches correctly. And because `shared(args, gen)` carries its schema, the **validator half
tests separately**: `assert.throws(() => mutators.createIssue.args.parse({ title: 42 }))` — that
parse is exactly what the server runs against untrusted wire args.

These tests are microseconds each. They're the right home for normalization, guard branches, and
"which ops does this emit" — the logic that's actually yours.

## Rung 2: the real engine, synchronously

To assert **query results** rather than op logs, run the same mutators against the real wasm
engine. `createOptimisticStore` wires schema + mutators + a source. For tests, the source is a
stub that never confirms anything. That is precisely the point — it isolates the
*prediction*, the thing your users see first:

```ts
// test/store.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { initWasm } from "@rindle/wasm";
import { createOptimisticStore } from "@rindle/optimistic";
import type { NormalizedEvent, OptimisticSource, ProgressFrame, QueryId } from "@rindle/client";
import { mutators, schema } from "../shared/app-def.ts";

await initWasm();   // once per process, before any store

/** A source that never confirms — the test exercises only the local prediction. */
class NullSource implements OptimisticSource {
  registerQuery(): void {}
  unregisterQuery(): void {}
  pushMutation(): Promise<void> { return Promise.resolve(); }
  onNormalized(_h: (qid: QueryId, ev: NormalizedEvent) => void): void {}
  onProgress(_h: (frame: ProgressFrame) => void): void {}
}

test("setStatus moves an issue between filtered views", () => {
  const { store, mutate } = createOptimisticStore(schema, new NullSource(), mutators, {
    clientID: "test",
    user: () => "alice",
  });
  const open = store.query.issue.where.status("todo").materialize();

  mutate.createIssue({ id: "i1", title: "Ship it", status: "todo", priority: "high", createdAt: 1 });
  assert.equal(open.data.length, 1);            // synchronous: prediction applied before returning

  mutate.setStatus({ id: "i1", status: "done", updatedAt: 2 });
  assert.equal(open.data.length, 0);            // the view moved incrementally, no refetch
});
```

Note there's no `await` after `initWasm()` — the engine applies each prediction and
folds every open view *inside* the `mutate` call. Two more behaviors worth pinning here: a mutator
that **throws rolls its whole prediction back** (`assert.throws(() => mutate.bad(...))` then assert
the view is unchanged). And a column typo throws with the valid names listed — your schema is the
test's safety net.

## Queries and fragments

Queries are values, so the fastest query test never materializes anything — it asserts the **AST**.
This is the right place to pin fragment composition ("these three fragments assemble into the one
query I think they do"):

```ts
import { stableKey } from "@rindle/client";

const composed = q.issue.where.id("i1").include(IssueCard).ast();
assert.equal(stableKey(composed), stableKey(expectedAst));   // same wire identity ⇒ one lease
```

When you want actual rows, seed a bare store and materialize the real named query — no mutators, no
source, just data in and rows out:

```ts
import { createWasmStore } from "@rindle/wasm";

const store = await createWasmStore(schema);
const view = store.materialize(issuesPage({ limit: 10 }));       // materialize first: live from row 0
await store.write((tx) => {
  tx.add("issue", { id: "i1", title: "a", status: "todo", priority: "low", createdAt: 2 });
  tx.add("issue", { id: "i2", title: "b", status: "todo", priority: "low", createdAt: 1 });
});
assert.deepEqual(view.data.map((r) => r.id), ["i1", "i2"]);      // orderBy proven, incrementally
```

`store.readOnce(query)` is the one-shot variant when you don't need the live view. Use
`createWasmStore` when queries are the subject and `createOptimisticStore` when mutators are.

## Rung 3: end to end, against the real stack

The rungs above never leave the process, so they can't see the things that live between tiers:
authorization, server-side arg parsing, rejection → snap-back, and two clients converging. For
those, boot the real thing. [`rindle dev`](/docs/rindle-cli) starts the write-master + follower
pair and exports `RINDLE_URL` / `RINDLE_DATABASE_TOKEN`. Your api-server starts against them, and
the test drives real `createRindleClient` instances at it:

```ts
// test/smoke.e2e.ts — run as `node test/smoke.e2e.ts`, not under --test
setTimeout(() => { console.error("e2e watchdog"); process.exit(1); }, 180_000).unref();

const alice = await makeClient("alice");         // createRindleClient against the live api URL
const bob = await makeClient("bob");

const bobView = bob.store.materialize(issuesPage({ limit: 10 }));
alice.mutate.createIssue({ id: "e2e-1", title: "hello", /* … */ });
await until(() => bobView.data.some((r) => r.id === "e2e-1"));   // poll: replication is real here

const rejections: string[] = [];                  // wire onRejected in makeClient to capture these
alice.mutate.createIssue({ id: "e2e-2", title: "spam", /* … */ });
await until(() => rejections.length === 1);       // server threw; alice's phantom row snapped back
```

Two things distinguish this rung. **Time is real** — writes reach the follower eventually, so
assertions poll with a deadline instead of reading synchronously. And **processes are real** — keep
an unref'd watchdog timer, since open daemon and api-server handles will otherwise hold a wedged
test alive forever. Keep e2e files out of your unit glob (`test/*.test.ts` vs `test/*.e2e.ts`) so
`node --test` never runs them. Run them as their own CI step.

## Conventions that keep this cheap

- **No framework.** `node --test` + `node:assert/strict` covers all of it. A typical script is
  `"test": "tsc --noEmit && node --test test/*.test.ts"` — the typecheck is itself a test layer
  (mutator ops and query builders are schema-typed).
- **Rungs 1 and only-AST query tests need no wasm.** Rung 2 needs the wasm artifact
  (`@rindle/wasm`) and one `await initWasm()` at module top.
- **Isolate identity.** Give each store/client a fixed test `clientID`. `resetStableClientID` and
  `deleteLocalPersistence` (from `@rindle/optimistic`) reset the browser-persisted bits between
  runs that touch them.

## See also

- [Isomorphic mutators](/docs/mutators) — the determinism rules that make rung 1 possible.
- [Compose the UI with fragments](/docs/fragments) — the composition the AST tests pin.
- [Handling rejected writes](/docs/rejected-writes) — the snap-back behavior the e2e rung proves.
- [The CLI](/docs/rindle-cli) — `rindle dev`, the pair your e2e boots.

---

[View this page on Rindle](https://rindle.sh/docs/testing)
