QUESTPIE
Code

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.

View markdown

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/testing

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

ImportWhat you get
@questpie/testingcreateTestApp. Your app, in process, on PGlite.
@questpie/testing/scenariocreateDisposablePostgres and startProductionServer.

Stand the app up

test/posts.test.ts
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.

  1. Create the PGlite database.
  2. Call your factory with a runtime it built.
  3. Await waitForInit().
  4. 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.

KeyWhat the harness uses
dbThe PGlite client. This one is not yours to set.
app.urlhttp://questpie.test
secretA fixed constant string
email.adapterAn adapter that swallows every message
logger.adapterAn 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" },
});
FieldEffect
clientUse this instance instead of creating one.
ownership"caller", the default, leaves your client open. "harness" closes it.
extensionsExtra 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.

FactorykindWhat 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

TopicPage
Seeding users, sessions and OAuthActors
A real PostgreSQL database per runDisposable PostgreSQL
Driving the server you actually deployProduction server
The rules an actor exercisesAccess control

Next

Disposable PostgreSQL is the other entry point. It leases a real database per run and drops it when you are done.

On this page