Testing
Unit-test queries, mutations, and actions in memory with lunoraTest — no worker, no network.
Last updated:
You test Lunora functions the same way you write them: as plain handlers that
take a ctx and args. The @lunora/testing harness
runs those handlers against a fresh in-memory SQLite database (no Durable
Object, no wrangler, no network), so a query/mutation/action suite is a normal,
fast Vitest run.
pnpm add -D @lunora/testingnpm install -D @lunora/testingyarn add -D @lunora/testingbun add -D @lunora/testingThe in-memory harness
lunoraTest(schema) runs your migrations against a fresh node:sqlite
database, builds the same ctx.db writer the real ShardDO builds, and returns
a harness whose methods execute a registered function's handler directly. All
methods share one database, so a write from a mutation is visible to a later
query, which is exactly the reactivity guarantee your app relies on.
import { defineSchema, defineTable, initLunora, v } from "lunorash/server";
import { lunoraTest } from "@lunora/testing";
import { expect, test } from "vitest";
const schema = defineSchema({
messages: defineTable({
author: v.string(),
body: v.string(),
}),
});
// A self-contained test derives its builders from the inline schema. In your
// app you instead import `query` / `mutation` from `@/lunora/_generated/server`.
const { mutation, query } = initLunora.dataModel().create();
const send = mutation.input({ author: v.string(), body: v.string() }).mutation(({ ctx, args }) => ctx.db.insert("messages", args));
const list = query.query(({ ctx }) => ctx.db.query("messages").collect());
test("sends and lists a message", async () => {
const t = lunoraTest(schema);
await t.mutation(send, { author: "ada", body: "hi" });
expect(await t.query(list, {})).toHaveLength(1);
t.close();
});Each lunoraTest(...) opens one native in-memory database. Call t.close()
(idempotent) to release it, typically in an afterEach:
import { afterEach } from "vitest";
const open: ReturnType<typeof lunoraTest>[] = [];
afterEach(() => {
while (open.length > 0) {
open.pop()?.close();
}
});Harness methods
lunoraTest(schema) returns a TestHarness:
| Method | Description |
|---|---|
query(reference, args) | Run a registered query. Internal queries are rejected on this external surface. |
mutation(reference, args) | Run a registered mutation. Internal mutations are rejected here. |
action(reference, args) | Run a registered action. Internal actions are rejected here. |
query(inline) | Run an inline (ctx) => … with a read-only QueryCtx. |
mutation(inline) | Run an inline (ctx) => … with a read-write MutationCtx. |
action(inline) | Run an inline (ctx) => … with an ActionCtx. |
run(fn) | Direct db access at mutation level (read + write), mirroring Convex's run. |
withIdentity(identity) | A harness view sharing this db but reporting the given identity on ctx.auth. |
close() | Close the in-memory database. Idempotent. |
Each method accepts either a registered function reference plus its args,
or a bare inline handler, handy for poking at ctx.db directly without
registering a function:
test("inline access to the db", async () => {
const t = lunoraTest(schema);
await t.run(async (ctx) => ctx.db.insert("messages", { author: "grace", body: "from run" }));
const count = await t.query(async (ctx) => (await ctx.db.query("messages").collect()).length);
expect(count).toBe(1);
t.close();
});Asserting reactive results
There is no live socket in the harness, and you don't need one. The reactivity contract is "a query re-run after a write sees the write," and because every method shares the same database, you assert it directly: run the mutation, then re-run the query and assert on the rows it now returns.
test("a send shows up in the next list", async () => {
const t = lunoraTest(schema);
expect(await t.query(list, {})).toHaveLength(0);
await t.mutation(send, { author: "ada", body: "hi" });
expect(await t.query(list, {})).toHaveLength(1);
t.close();
});Proving the full live path (SSR preload → WebSocket push → second-tab update) needs a running, scaffolded app and is a manual procedure, not a unit test. See Manual end-to-end verification.
Auth and row-level security
withIdentity(identity) returns a harness view that shares the same database
but reports the given identity on ctx.auth. userId is the subject handlers
read from ctx.auth.userId; extra fields come back from ctx.auth.getIdentity()
(mirroring a decoded JWT). Drive the same procedure with and without an identity
to test authorization:
const whoAmI = query.query(({ ctx }) => ctx.auth.userId);
test("reflects the injected identity", async () => {
const t = lunoraTest(schema);
await expect(t.query(whoAmI, {})).resolves.toBeNull();
const scoped = t.withIdentity({ userId: "u1" });
await expect(scoped.query(whoAmI, {})).resolves.toBe("u1");
t.close();
});Writes made under the scoped view persist for every accessor; the view is a lens onto identity, not a separate database.
Internal functions
internalQuery / internalMutation / internalAction are unreachable from the
external RPC boundary in production. The harness enforces that contract: calling
one through t.query / t.mutation / t.action throws, while reaching it
through ctx.runQuery / ctx.runMutation / ctx.runAction from another
function succeeds.
import { initLunora, v } from "lunorash/server";
const { internalMutation, mutation } = initLunora.dataModel().create();
const internalSend = internalMutation.input({ author: v.string(), body: v.string() }).mutation(({ ctx, args }) => ctx.db.insert("messages", args));
const sendViaInternal = mutation.input({ author: v.string(), body: v.string() }).mutation(({ ctx, args }) => ctx.runMutation(internalSend, args));
test("internal functions are only reachable via ctx.run*", async () => {
const t = lunoraTest(schema);
await expect(t.mutation(internalSend, { author: "ada", body: "leak" })).rejects.toThrow("is an internal function");
await t.mutation(sendViaInternal, { author: "grace", body: "ok" });
expect(await t.query(list, {})).toHaveLength(1);
t.close();
});What the harness stubs (v1)
ctx.storage, ctx.scheduler, ctx.vectors, and an action's ctx.fetch are
clearly-throwing stubs. A handler that never touches one runs fine; one that
does fails with a descriptive error the first time it reaches for it (e.g.
ctx.scheduler is not available in the in-memory @lunora/testing harness (v1)).
HTTP actions, scheduled-function draining, real R2 storage, .global() / D1
tables, and Vectorize are deferred to a follow-up; exercise those over a
running worker instead.
Mail-catcher helpers (E2E)
In lunora dev, @lunora/mail captures every outbound
email (sign-up verification, forgot-password, magic links) into the studio's
root-shard inbox instead of hitting a real provider. The mail-catcher helpers,
re-exported from @lunora/testing, read that inbox over the admin RPC, so a
Playwright (or any HTTP) test can drive "request reset → read the email → follow
the link → set a new password" deterministically.
| Export | Description |
|---|---|
listCapturedMail | Read the captured-mail inbox, newest first. |
waitForMail | Poll the inbox until a message matching to (+ optional subject). |
extractLink | Pull the first link out of a captured message (html first, then text). |
waitForMail takes the worker baseUrl, the admin bearer token (the worker
gates introspection behind LUNORA_ADMIN_TOKEN), the recipient to, and an
optional subjectMatch. It polls (every 250ms, timing out after 10s by default)
and returns the matching message, or throws on timeout.
import { extractLink, waitForMail } from "@lunora/testing";
import { expect, test } from "vitest";
test("password reset email carries a working link", async () => {
const baseUrl = "http://localhost:8787";
const adminToken = process.env.LUNORA_ADMIN_TOKEN!;
await fetch(`${baseUrl}/api/auth/forgot-password`, {
body: JSON.stringify({ email: "alice@example.test" }),
headers: { "content-type": "application/json" },
method: "POST",
});
const mail = await waitForMail({ adminToken, baseUrl, to: "alice@example.test", subjectMatch: "Reset your password" });
const resetLink = extractLink(mail, { match: "/reset-password" });
expect(resetLink).toContain("/reset-password");
// → visit `resetLink`, set a new password, assert success.
});Running in CI
The harness is plain Vitest, so it runs anywhere pnpm run test does, with no
Cloudflare account or wrangler login needed. Two things to wire up:
lunora codegen --checkbefore the test job, so a stale_generated/is a build failure rather than tests passing against old types (the same gate deployment recommends before deploy).- For the mail-catcher E2E path, your CI must boot
lunora devand pass the worker'sbaseUrlandLUNORA_ADMIN_TOKENto the test; those helpers talk to a running worker, unlike the in-memory harness.
pnpm lunora codegen --check
pnpm run testSee also
- Queries & mutations — the
query/mutation/actionfunctions the harness runs - Manual end-to-end verification — proving the full live-loader path
- Deployment — the
codegen --checkCI gate - @lunora/testing — full harness and mail-catcher reference