Guide · 14

Test without a server.

Run your application in-process, call it synchronously, move the clock, and drive the real client from node --test.

@flower-js/sdk/testing runs an application on Flower's reference engine inside your test process. Calls behave like HTTP calls, with the same checks, receipts and errors, and time moves only when you say so. It needs Node.

Start a test database

counter.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { FlowerError } from "@flower-js/sdk";
import { testDatabase } from "@flower-js/sdk/testing";
import app from "./counter.ts";

test("increments, doubles and rejects bad input", async () => {
  const db = await testDatabase(app);
  assert.equal(db.mutate("counter.increment", { id: "visits", by: 2 }), 4);
  assert.deepEqual(db.query("counter.get", "visits"), { count: 2, doubled: 4 });
  assert.throws(() => db.mutate("counter.increment", { id: "visits", by: 0 }),
    (error) => error instanceof FlowerError && error.failure?.code === "INVALID_ARGUMENT");
});
Run it
node --test counter.test.ts
  • query, mutate and call are synchronous and return the value. They check arguments and access and keep receipts like HTTP calls, and throw a FlowerError with the same status, code and failure.
  • Pass options last: { credentials, requestId }. Reusing a request ID replays the first result.
  • Pass the imported module to run it directly, or a path, testDatabase<typeof app>("counter.ts"), to bundle it and run it in an isolated context as the server does.
  • testDatabase(app, { now, credentials, partitions }) sets the starting clock (1,000,000 ms by default), default credentials, and named partitions.

Control time and maintenance

db.now is the server clock that ctx.now() reads. Nothing moves it but you.

invoices.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { testDatabase } from "@flower-js/sdk/testing";
import invoices from "./invoices.ts";

test("an invoice is ready five seconds after its last update", async () => {
  const db = await testDatabase(invoices);
  db.mutate("invoice.update", { id: "inv-1", total: 2400 });
  db.advance(4_999);
  assert.equal(db.query("invoice.get", "inv-1")?.status, "draft");
  db.advance(1);
  assert.equal(db.query("invoice.get", "inv-1")?.status, "ready");
});
  • db.advance(ms) moves the clock, then runs due maintenance in every partition, like the leader would. It returns the number of committed runs.
  • db.maintain() runs due maintenance without moving the clock: background materialization, for example.
  • Maintenance never runs on its own, so a test sees exactly the state it arranged.

Drive the real client

db.client is a FlowerClient<typeof app> wired to the test database. query, mutate, call, watch, subscribe and waitUntil all work, live updates included, so worker code runs unchanged:

reactive-worker.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { testDatabase } from "@flower-js/sdk/testing";
import app from "./reactive-worker.ts";
import { runWorker } from "./reactive-worker-client.ts";

test("a worker keeps the digest current", async () => {
  const db = await testDatabase(app);
  const stop = new AbortController();
  const worker = runWorker(db.client, stop.signal);
  db.mutate("document.put", { id: "notes", text: "Hello, garden." });
  const { value } = await db.client.waitUntil("document.get", "notes",
    (document) => document?.digest?.status === "ready");
  assert.equal(value?.text, "Hello, garden.");
  stop.abort();
  await worker;
});

Partitions and transactions

Name partitions up front. Each has its own data, revision and receipts, and they share the clock. Transactions across them commit together or not at all, in-process.

bank.test.ts
import assert from "node:assert/strict";
import test from "node:test";
import { FlowerError } from "@flower-js/sdk";
import { testDatabase } from "@flower-js/sdk/testing";
import bank from "./bank.ts";

test("transfers move money between partitions atomically", async () => {
  const db = await testDatabase(bank, { partitions: ["west", "east"] });
  const west = db.partition("west");
  west.mutate("credit", { id: "alice", cents: 500 });
  assert.deepEqual(db.call("transfer", { from: "alice", to: "bob", cents: 200 }).results, [300, 200]);
  assert.throws(() => db.call("transfer", { from: "alice", to: "bob", cents: 900 }),
    (error) => error instanceof FlowerError && error.failure?.code === "INSUFFICIENT_FUNDS");
  assert.equal(west.mutate("debit", { id: "alice", cents: 300 }), 0); // The failed transfer moved nothing.
});

db.partition(name) has query, mutate, call, maintain and its own client. As on a server, if the app has access checks, a principal's tenant must match the partition.

What differs from a server

  • No native crypto. NaCl, JWT, managed keys and jwtBearer fail inside the test database. Test access rules with your own authenticate function.
  • Undeclared indexes fail. ctx.range, ctx.query and ctx.scan({ index }) fail with UNDECLARED_INDEX when the index isn't declared through define({ collections }) or a component, because the server would quietly scan the whole collection. A forgotten declaration shows up in tests instead of as a slowdown in production.
  • One process, no replication: replica lag, budgets, deployment steps and retry sessions aren't simulated.
  • Watches send full snapshots rather than patches.
  • db.data is a copy of the raw state for debugging. Its format isn't stable.