Skip to content
Guides contents

GuidesTesting & troubleshooting

Testing your app

Test query definitions and mutators, then validate optimistic reconciliation and synchronization with real clients.

View as Markdown

Use tests at three levels: application logic, local query behavior, and the real sync path. Each level answers a different question.

Test What it proves What it does not prove
A mutator with a recorded operation log Validation and emitted write operations Database behavior or authorization
The WASM engine with local data Query results and optimistic predictions Server acceptance or convergence
Two clients with the API server and data tier Authorization, rejection, and synchronization Every application query or UI state

The runnable examples here use a small independent issue schema. They do not import helpers from another guide or require a running server. After running them, replace the fixture with your application’s shared definitions.

Prepare the test project

Use Node 22.18 or later. In an empty directory, create package.json:

{
  "name": "rindle-app-tests",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "tsc --noEmit && node --test test/*.test.ts"
  }
}

Install the dependencies:

pnpm add @rindle/client @rindle/wasm @rindle/optimistic zod
pnpm add -D typescript @types/node

Add the TypeScript configuration:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "allowImportingTsExtensions": true,
    "noEmit": true,
    "strict": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["test/**/*.ts"]
}

Node runs the TypeScript after removing type annotations. It does not typecheck the files. The tsc step performs that check first.

Define the fixture

The fixture supplies every table, query, and mutator used by the tests:

// test/fixture.ts
import {
  boolean, createSchema, defineFragment, defineMutators,
  defineQuery, newQueryBuilder, string, table,
} from "@rindle/client";
import { z } from "zod";

export const issue = table("issue").columns({
  id: string(), title: string(), closed: boolean(), ownerId: string(),
}).primaryKey("id");

export const schema = createSchema({ tables: [issue] });
export const q = newQueryBuilder(schema);

export const IssueTitleFragment = defineFragment(issue, (row) =>
  row.select("id", "title"),
);

export const openIssuesQuery = defineQuery("openIssues", () =>
  q.issue.where.closed(false).orderBy("id", "asc").include(IssueTitleFragment),
);

const { shared } = defineMutators(schema);

export const mutators = {
  createIssue: shared(
    z.object({ id: z.string().min(1), title: z.string().min(1).max(200) }),
    function* (tx, args, ctx) {
      const title = args.title.trim();
      if (title.length === 0) throw new Error("Title must contain text");
      yield tx.insert("issue", {
        id: args.id, title, closed: false, ownerId: ctx.user,
      });
    },
  ),
  setClosed: shared(
    z.object({ id: z.string(), closed: z.boolean() }),
    function* (tx, args) {
      yield tx.update("issue", args);
    },
  ),
};

createIssue trims the title inside the mutator body. It obtains ownerId from the execution context, not the input arguments. This fixture illustrates application behavior. It is not a complete ownership policy.

Record a mutator’s operations

driveMutationSync runs the generator against an executor that you provide. This executor records writes and supplies empty read results:

// test/mutators.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { driveMutationSync, isoTx } from "@rindle/client";
import type { MutationOp } from "@rindle/client";
import { mutators } from "./fixture.ts";

test("createIssue trims the title and uses the acting user", () => {
  const operations: MutationOp[] = [];
  const generator = mutators.createIssue(
    isoTx,
    { id: "i1", title: "  Ship it  " },
    { user: "alice" },
  );
  driveMutationSync(generator, {
    apply: (operation) => { operations.push(operation); },
    read: () => undefined,
    query: () => [],
  });
  assert.deepEqual(operations, [{
    kind: "insert",
    table: "issue",
    row: { id: "i1", title: "Ship it", closed: false, ownerId: "alice" },
  }]);
});

test("the argument schema rejects an invalid title type", () => {
  assert.throws(() => mutators.createIssue.args.parse({ id: "i1", title: 42 }));
});

A direct generator call does not parse its argument schema. Neither does the optimistic backend automatically parse a shared mutator’s schema. The API server parses untrusted arguments before execution. The separate validation test exercises that parser explicitly.

For a mutator that reads data, supply representative rows through read and query. Cover each branch of the mutator’s guards. An operation log does not prove that a SQL transaction or local engine accepts those writes.

Exercise a maintained query

This test uses the real WASM engine. createWasmStore initializes it in Node and reads the WASM bytes from the installed package.

// test/queries.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { createWasmStore } from "@rindle/wasm";
import { openIssuesQuery, schema } from "./fixture.ts";

test("the open issue list follows inserts, edits, and removals", async (context) => {
  const store = await createWasmStore(schema);
  const view = store.materialize(openIssuesQuery());
  context.after(() => view.destroy());

  const first = { id: "i1", title: "First", closed: false, ownerId: "alice" };
  const second = { id: "i2", title: "Second", closed: false, ownerId: "bob" };
  await store.write((tx) => {
    tx.add("issue", second);
    tx.add("issue", first);
  });
  assert.deepEqual(view.data, [
    { id: "i1", title: "First" },
    { id: "i2", title: "Second" },
  ]);

  await store.write((tx) => tx.edit("issue", first, { ...first, closed: true }));
  assert.deepEqual(view.data, [{ id: "i2", title: "Second" }]);

  await store.write((tx) => tx.remove("issue", second));
  assert.deepEqual(view.data, []);
});

The test checks ordering, filtering, fragment projection, and changes to an existing view. It does not reconstruct the view after each write. For a single result read, store.readOnce(query) creates and releases a temporary view.

Exercise a prediction before confirmation

An optimistic source connects the backend to server data and mutation acknowledgements. This test source deliberately sends neither. It isolates the local prediction, which changes views before mutate returns.

// test/optimistic.test.ts
import { test } from "node:test";
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { defineMutators } from "@rindle/client";
import type { NormalizedEvent, OptimisticSource, ProgressFrame, QueryId } from "@rindle/client";
import { initWasm } from "@rindle/wasm";
import { createOptimisticStore } from "@rindle/optimistic";
import { mutators, openIssuesQuery, schema } from "./fixture.ts";

await initWasm();

class SilentSource implements OptimisticSource {
  registerQuery(): void {}
  unregisterQuery(): void {}
  pushMutation(): Promise<void> { return Promise.resolve(); }
  onNormalized(_handler: (queryId: QueryId, event: NormalizedEvent) => void): void {}
  onProgress(_handler: (frame: ProgressFrame) => void): void {}
}

const { shared } = defineMutators(schema);
const registry = {
  ...mutators,
  failAfterInsert: shared(
    mutators.createIssue.args,
    function* (tx, args, ctx) {
      yield tx.insert("issue", {
        id: args.id, title: args.title, closed: false, ownerId: ctx.user,
      });
      throw new Error("Deliberate test failure");
    },
  ),
};

test("predictions update views synchronously and a failed body rolls back", (context) => {
  const { store, mutate } = createOptimisticStore(schema, new SilentSource(), registry, {
    clientID: randomUUID(),
    user: () => "alice",
  });
  const view = store.materialize(openIssuesQuery());
  context.after(() => view.destroy());

  mutate.createIssue({ id: "i1", title: "Ship it" });
  assert.deepEqual(view.data, [{ id: "i1", title: "Ship it" }]);

  mutate.setClosed({ id: "i1", closed: true });
  assert.deepEqual(view.data, []);

  assert.throws(
    () => mutate.failAfterInsert({ id: "i2", title: "Must not appear" }),
    /Deliberate test failure/,
  );
  assert.deepEqual(view.data, []);
  assert.equal(view.resultType, "unknown");
});

The source accepts outgoing calls without confirming them. Those writes remain pending, and the named query’s coverage remains unknown. This test does not simulate a server rejection or a rebase. Its final assertion prevents a local prediction from being mistaken for server confirmation.

Run all four tests:

pnpm test

Each test creates a fresh store and destroys its materialized view. The silent source opens no sockets or timers. For tests using createRindleClient, also call client.close() during teardown. Use distinct client IDs when multiple clients share one authority.

Cover the real sync path

For integration tests, use a test database and the app’s real API server. The synced-app quickstart defines both server and client startup. rindle dev starts the local data tier and supplies its connection environment to your app processes.

A complete sync test covers these steps:

  1. Create two authenticated clients with distinct client IDs.
  2. Retain the same named query on both clients.
  3. Wait for complete coverage before checking the initial result.
  4. Mutate through client A and assert its immediate local prediction.
  5. Wait for client B to receive the accepted change.
  6. Cause a known server rejection and assert the rejection callback and corrected local result.
  7. Release both views and close both clients in teardown.

Use a deadline for every wait. A short sleep does not establish that synchronization is complete. Record rejection reasons so a timeout does not hide an authorization or validation error. Also cover a user who cannot read the query, reconnect behavior, and an empty authoritative result.

These tests can use node --test or your existing runner. They need real service startup and cleanup in CI. The repository’s issue-tracker end-to-end test shows a real two-client harness with bounded waits and process cleanup.

For UI tests, distinguish pending mutations from query completeness. Rejected writes describes the failure behavior, and fragments describes the React read boundaries.