Testing
A dev-only package that stands your generated app on a throwaway PGlite database, hands you typed callers, and tears the whole thing down when the file ends.
How do you call your own collections in a test, as a real user, against a real database? Install one package and write three lines.
Install
bun add -d @questpie/testingNothing in questpie imports it. It never reaches your production build.
The peer version is exact
The package pins questpie as an exact peer. @questpie/testing@3.21.1 wants
questpie@3.21.1. Upgrade the two together.
It ships two entry points.
| Import | What you get |
|---|---|
@questpie/testing | createTestApp. Your app, in process, on PGlite. |
@questpie/testing/scenario | createDisposablePostgres and startProductionServer. |
Stand the app up
import { afterAll, expect, it } from "bun:test";
import { createTestApp } from "@questpie/testing";
import { createAppForRuntime } from "#questpie/app-factory";
const testApp = await createTestApp({ createApp: createAppForRuntime });
afterAll(() => testApp.dispose());Import the factory from #questpie/app-factory, not from #questpie. The
generated index boots one shared app the moment you import it, using your real
questpie.config.ts. createAppForRuntime takes a runtime and returns a new
app, so the harness can hand it a database of its own.
createTestApp then runs four steps in this order.
- Create the PGlite database.
- Call your factory with a runtime it built.
- Await
waitForInit(). - Run
migrations.up().
Steps 3 and 4 are each bounded by timeoutMs, 15 seconds by default. A failure
in any step destroys what was already built and throws TestAppSetupError.
Read .phase for the step that broke and .cause for the original error.
The runtime it builds
runtime takes everything a real app takes. The database is the harness's, and
four more keys have test defaults.
| Key | What the harness uses |
|---|---|
db | The PGlite client. This one is not yours to set. |
app.url | http://questpie.test |
secret | A fixed constant string |
email.adapter | An adapter that swallows every message |
logger.adapter | An adapter that drops every line |
Everything else is yours. Pass queue, storage, search, kv or realtime.
const testApp = await createTestApp({
createApp: createAppForRuntime,
runtime: { app: { url: "https://tenant.example.test" } },
timeoutMs: 30_000,
});The PGlite lifecycle
Every createTestApp call builds its own in-memory PGlite database, with the
pg_trgm module loaded. Two harnesses in one file cannot see each other's rows.
Tables come from migrations.up(), the same committed migrations you deploy.
A column you only ever applied with questpie push does not exist here.
Generate the migration first. See Migrations.
dispose() destroys the app and closes the database. It is memoized, so two
calls, or two concurrent calls, do one teardown. A failed teardown throws
TestAppCleanupError, and .errors holds every failure it collected.
Bring your own client
Pass database.client to reuse an @electric-sql/pglite client across files.
const database = await PGlite.create({ extensions: { pg_trgm } });
const testApp = await createTestApp({
createApp: createAppForRuntime,
database: { kind: "pglite", client: database, ownership: "harness" },
});| Field | Effect |
|---|---|
client | Use this instance instead of creating one. |
ownership | "caller", the default, leaves your client open. "harness" closes it. |
extensions | Extra PGlite modules, merged with pg_trgm. |
extensions works only when the harness creates the client, because PGlite
loads modules at creation. Pass both and setup fails in the database phase. A
migration that runs CREATE EXTENSION needs its module declared here first.
Actors carry the authority
testApp.app is your app, fully typed. Reach it directly and you run in system
mode. To test what a caller may do, ask for an actor.
| Factory | kind | What the context carries |
|---|---|---|
anonymous() | "anonymous" | session: null, accessMode: "user" |
actor({ user }) | "user" | A session built around your user |
actor({ session }) | "user" | Exactly the session you passed |
oauth({ session, clientId, scopes, tokenId }) | "oauth" | An oauth principal carrying scopes |
system() | "system" | session: undefined, accessMode: "system" |
run() builds a fresh context, opens the ambient scope, and calls back with
{ app, context }. It resolves to whatever your callback returned.
const editor = testApp.actor({
user: { id: "editor-1", email: "editor@example.test", name: "Editor" },
});
it("lets an editor create and refuses an anonymous caller", async () => {
await editor.run(({ app, context }) =>
app.collections.posts.create({ title: "Draft", slug: "draft" }, context),
);
await expect(
testApp
.anonymous()
.run(({ app, context }) =>
app.collections.posts.create({ title: "Nope", slug: "nope" }, context),
),
).rejects.toThrow();
});Passing context is explicit above, not required. run() opens the same
AsyncLocalStorage scope an HTTP request opens. So getContext() works inside
the callback, and a call you hand no context inherits the actor's session and
access mode. See Context.
actor({ user }) wants every field your user table requires, and fills in the
session around it. Actors covers that seed, the
apps where it stops compiling, and how OAuth scopes reach an access rule.
Mail and logs stay quiet
The harness installs a mail adapter whose send does nothing. A hook that
sends a welcome email neither prints nor delivers, and the template still
renders. Without it every message would land in your test output, and a run
with NODE_ENV=production would throw for a missing adapter.
To assert on mail, pass an adapter that records:
import { MailAdapter, type SerializableMailOptions } from "questpie/mailer";
class RecordingMail extends MailAdapter {
readonly sent: SerializableMailOptions[] = [];
async send(options: SerializableMailOptions) {
this.sent.push(options);
}
}
const mail = new RecordingMail();
const testApp = await createTestApp({
createApp: createAppForRuntime,
runtime: { email: { adapter: mail } },
});runtime.logger works the same way. See Email.
Where each topic lives
| Topic | Page |
|---|---|
| Seeding users, sessions and OAuth | Actors |
| A real PostgreSQL database per run | Disposable PostgreSQL |
| Driving the server you actually deploy | Production server |
| The rules an actor exercises | Access control |
Next
Disposable PostgreSQL is the other entry point. It leases a real database per run and drops it when you are done.