Recipes

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.

View as Markdown

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

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

// 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”):

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:

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

// 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