# Testing (/docs/code/testing)

---
title: Testing
description: 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.
kind: guide
package: "@questpie/testing"
---

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

```bash
bun add -d @questpie/testing
```

Nothing in `questpie` imports it. It never reaches your production build.

<Callout type="warn" title="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.
</Callout>

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

```ts title="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.

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

```ts
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](/docs/ship/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.

```ts
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.

```ts
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](/docs/code/context).

`actor({ user })` wants every field your user table requires, and fills in the
session around it. [Actors](/docs/code/testing/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:

```ts
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](/docs/infrastructure/email).

## Where each topic lives

| Topic                                  | Page                                                            |
| -------------------------------------- | --------------------------------------------------------------- |
| Seeding users, sessions and OAuth      | [Actors](/docs/code/testing/actors)                             |
| A real PostgreSQL database per run     | [Disposable PostgreSQL](/docs/code/testing/disposable-postgres) |
| Driving the server you actually deploy | [Production server](/docs/code/testing/production-server)       |
| The rules an actor exercises           | [Access control](/docs/schema/access-control)                   |

## Next

**[Disposable PostgreSQL](/docs/code/testing/disposable-postgres)** is the other
entry point. It leases a real database per run and drops it when you are done.
