Testing toolkit for Lunora: an in-memory harness for queries, mutations, and actions
[![typescript-image][typescript-badge]][typescript-url]
[![FSL-1.1-Apache-2.0 licence][license-badge]][license]
[![npm version][npm-version-badge]][npm-version]
[![npm downloads][npm-downloads-badge]][npm-downloads]
[![PRs Welcome][prs-welcome-badge]][prs-welcome]
---
Daniel Bannert's open source work is supported by the community on GitHub Sponsors
---
Testing toolkit for Lunora: an in-memory harness for queries, mutations, and actions. Today it surfaces the dev mail-catcher helpers — in `lunora dev`, `@lunora/mail` captures every outbound email into the studio's root-shard inbox, and these helpers read that inbox over the admin RPC so a Playwright (or any HTTP) test can drive "request reset → read the email → follow the link" deterministically.
Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-safe, real-time backend on Cloudflare Workers + Durable Objects with a Vite-first DX.
## Install
```sh
npm install @lunora/testing
```
```sh
yarn add @lunora/testing
```
```sh
pnpm add @lunora/testing
```
## Usage
### In-memory function harness
`lunoraTest(schema)` runs your `query` / `mutation` / `action` functions against
an in-memory `node:sqlite` backend — no Durable Object, no `wrangler`, no
network. It mirrors Convex's `convexTest`: `query` / `mutation` / `action` /
`run` / `withIdentity`, all sharing one database so a write is visible to a
later read.
In your app, `query` / `mutation` / `action` are imported from
`@/lunora/_generated/server` — the codegen-emitted module. A self-contained test
can instead derive the builders from a schema with `initLunora`:
```ts
import { defineSchema, defineTable, initLunora, v } from "@lunora/server";
import { lunoraTest } from "@lunora/testing";
import { expect, test } from "vitest";
const schema = defineSchema({
messages: defineTable({
author: v.string(),
body: v.string(),
}),
});
const { mutation, query } = initLunora.dataModel().create();
const send = mutation.input({ author: v.string(), body: v.string() }).mutation(({ args, ctx }) => 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);
});
test("sees the injected identity", async () => {
const t = lunoraTest(schema).withIdentity({ userId: "u1" });
await t.run(async (ctx) => {
expect(ctx.auth.userId).toBe("u1");
});
});
```
Each `lunoraTest(...)` opens an in-memory SQLite database; call `t.close()`
(e.g. in an `afterEach`) to release the native handle when a test finishes.
#### Injectable `ctx.fetch`
Pass a `fetch` option to replace the throwing stub in action contexts:
```ts
const fakeFetch = vi.fn().mockResolvedValue(Response.json({ ok: true }));
const t = lunoraTest(schema, { fetch: fakeFetch });
// ctx.fetch inside any action now calls fakeFetch
```
Without the option, `ctx.fetch` still throws the v1 error on first access.
#### Fixed `ctx.now`
Pass a `now` option (epoch ms) to pin `ctx.now` across every context, so
time-dependent handlers are deterministic:
```ts
const t = lunoraTest(schema, { now: 1_700_000_000_000 });
// ctx.now === 1_700_000_000_000 in every query/mutation/action
```
Defaults to the wall clock captured at harness creation.
#### Controllable in-memory scheduler
`ctx.scheduler` is a fully functional fake. Jobs are enqueued synchronously but
**only execute** when you advance the virtual clock:
```ts
// The mutation the scheduler will dispatch, registered under "messages:send".
const sendMessage = mutation.input({ author: v.string(), body: v.string() }).mutation(({ args, ctx }) => ctx.db.insert("messages", args));
// A mutation that schedules `sendMessage` 5s out instead of writing directly.
const enqueue = mutation.mutation(({ ctx }) => ctx.scheduler.runAfter(5_000, "messages:send", { author: "ada", body: "hello" }));
test("scheduled mutation writes to db after advance", async () => {
const t = lunoraTest(schema, {
functions: { "messages:send": sendMessage }, // path → registered fn
});
await t.mutation(enqueue, {});
expect(t.scheduler.list()).toHaveLength(1); // queued, not yet run
await t.scheduler.advance(10_000); // tick the clock past scheduledFor
expect(await t.query(list, {})).toHaveLength(1); // sendMessage ran
});
```
Harness controls:
| Method | Description |
| -------------------------- | ----------------------------------------------------- |
| `t.scheduler.list()` | Snapshot of all pending jobs (enqueue order) |
| `t.scheduler.advance(ms)` | Tick the virtual clock by `ms`, execute due jobs |
| `t.scheduler.runPending()` | Execute all pending jobs regardless of scheduled time |
The virtual clock is **per-harness** — advancing one harness's clock does not
affect other harnesses, so tests running in parallel are isolated.
Provide a `functions` map (`{ "path:name": registeredFn }`) so the scheduler
can dispatch jobs. Paths not in the map produce a `console.warn` and are
silently dropped (matching production behaviour for unknown paths).
#### Subscription testing
`harness.subscribe(query, args)` returns an async iterable that yields the query's
current result immediately, then re-emits after every `mutation` or `run` call:
```ts
test("subscription re-emits after mutation", async () => {
const t = lunoraTest(schema);
const sub = t.subscribe(list, {});
expect((await sub.next()).value).toHaveLength(0); // initial snapshot
await t.mutation(send, { author: "ada", body: "hi" });
expect((await sub.next()).value).toHaveLength(1); // updated snapshot
await sub.return(); // unsubscribe
});
```
Subscriptions are **table-agnostic** — any mutation triggers a re-evaluation.
Multiple independent subscriptions each maintain their own snapshot stream.
> **v1 stubs (still throwing).** `ctx.storage`, `ctx.vectors`, and `ctx.workflows`
> are clearly-throwing stubs — a handler that touches one fails with a
> "not available in the in-memory @lunora/testing harness (v1)" error.
> These are the next planned follow-ups.
### Mail-catcher helpers (E2E)
```ts
import { extractLink, waitForMail } from "@lunora/testing";
// Trigger the flow (e.g. POST /api/auth/forgot-password), then:
const mail = await waitForMail({
adminToken: process.env.LUNORA_ADMIN_TOKEN!,
baseUrl: "http://localhost:8787",
to: "alice@example.test",
subjectMatch: "Reset your password",
});
const resetLink = extractLink(mail, { match: "/reset-password" });
// → visit `resetLink`, set a new password, assert success.
```
> This README covers the basics. For the full API, options, and guides, see the **[documentation](https://lunora.sh/docs)**.
## Related
- [`@lunora/mail`](https://www.npmjs.com/package/@lunora/mail) — captures the outbound email these helpers read.
- [`@lunora/auth`](https://www.npmjs.com/package/@lunora/auth) — the auth flows (verification, reset, magic links) you test end-to-end.
- [`@lunora/cli`](https://www.npmjs.com/package/@lunora/cli) — runs the `lunora dev` server the harness drives.
## Supported Node.js Versions
Libraries in this ecosystem make the best effort to track [Node.js' release schedule](https://github.com/nodejs/release#release-schedule).
Here's [a post on why we think this is important](https://medium.com/the-node-js-collection/maintainers-should-consider-following-node-js-release-schedule-ab08ed4de71a).
## Contributing
If you would like to help take a look at the [list of issues](https://github.com/anolilab/lunora/issues) and check our [Contributing](https://github.com/anolilab/lunora/blob/alpha/.github/CONTRIBUTING.md) guidelines.
> **Note:** please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.
## Credits
- [Daniel Bannert](https://github.com/prisis)
- [All Contributors](https://github.com/anolilab/lunora/graphs/contributors)
## Made with ❤️ at Anolilab
This is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Anolilab](https://www.anolilab.com/open-source) is a Development and AI Studio. Contact us at [hello@anolilab.com](mailto:hello@anolilab.com) if you need any help with these technologies or just want to say hi!
## License
The Lunora testing package is open-sourced software licensed under the [FSL-1.1-Apache-2.0][license].
[license-badge]: https://img.shields.io/badge/license-FSL--1.1--Apache--2.0-blue.svg?style=for-the-badge
[license]: https://github.com/anolilab/lunora/blob/alpha/LICENSE.md
[npm-version-badge]: https://img.shields.io/npm/v/@lunora/testing?style=for-the-badge
[npm-version]: https://www.npmjs.com/package/@lunora/testing
[npm-downloads-badge]: https://img.shields.io/npm/dm/@lunora/testing?style=for-the-badge
[npm-downloads]: https://www.npmjs.com/package/@lunora/testing
[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge
[prs-welcome]: https://github.com/anolilab/lunora/blob/alpha/.github/CONTRIBUTING.md
[typescript-badge]: https://img.shields.io/badge/Typescript-294E80.svg?style=for-the-badge&logo=typescript
[typescript-url]: https://www.typescriptlang.org/