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/testing
npm install -D @lunora/testing
yarn add -D @lunora/testing
bun add -D @lunora/testing

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

MethodDescription
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.

ExportDescription
listCapturedMailRead the captured-mail inbox, newest first.
waitForMailPoll the inbox until a message matching to (+ optional subject).
extractLinkPull 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 --check before 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 dev and pass the worker's baseUrl and LUNORA_ADMIN_TOKEN to the test; those helpers talk to a running worker, unlike the in-memory harness.
pnpm lunora codegen --check
pnpm run test

See also