Actors
Four factories on the harness, the user seed each one wants, and what the callback gets. An actor is how a test says who is calling.
Which factory do you reach for, and what does it hand your callback? Every
actor comes off the createTestApp result and shares one method, run().
The factories
| Factory | kind | context.session | context.accessMode |
|---|---|---|---|
anonymous() | "anonymous" | null | "user" |
actor({ user }) | "user" | A built session | "user" |
actor({ session }) | "user" | Your session | "user" |
oauth({ session, clientId, scopes, tokenId }) | "oauth" | Your session | "user" |
system() | "system" | undefined | "system" |
An actor is cheap and stateless. Build one per identity at the top of the file
and reuse it. Each run() call builds a fresh context.
const editor = testApp.actor({ user: editorSeed });
const viewer = testApp.actor({ user: viewerSeed });
const post = await editor.run(({ app, context }) =>
app.collections.posts.create({ title: "Draft", slug: "draft" }, context),
);run() returns whatever the callback returns, awaited. It also opens the
AsyncLocalStorage scope, so getContext() resolves inside the callback and a
nested call with no context of its own inherits this one.
The user seed
actor({ user }) takes your app's user row, not a partial. Every field your
user table requires is required here too, including the ones your auth config
added. Three keys are optional, and the harness fills them in.
| Key | Default |
|---|---|
emailVerified | true |
createdAt | 2020-01-01T00:00:00.000Z |
updatedAt | The same timestamp |
It then wraps that user in a session. The id is <userId>-session, the token
is <userId>-token, and it expires on 2100-01-01. Nothing is written to the
database. The session exists to carry identity into your access rules.
const editor = testApp.actor({
user: {
id: "editor-1",
email: "editor@example.test",
name: "Editor",
role: "editor",
tenantId: "tenant-1",
},
});Some apps must pass the whole session
A Better Auth plugin can add a required field to the session record, such as
activeOrganizationId. The harness only knows the standard keys, so {user}
stops compiling for that app. Pass {session} and set the field yourself.
{ session } is always allowed. Reach for it when the seed will not do, or
when you want one session shared by a user actor and an OAuth actor.
OAuth actors
oauth() needs a full session plus the three values a token carries.
const session = await editor.run(({ context }) => context.session);
const agent = testApp.oauth({
session,
clientId: "test-client",
scopes: ["collections:posts:read"],
tokenId: "token-1",
});That builds a principal of { kind: "oauth", user, clientId, scopes, tokenId }
and puts it on the context. The access mode stays "user", so your own rules
still run as that user. The scopes are the extra gate on top. A rule that reads
principal.scopes sees this list, and so does the MCP scope gate. See
Scopes.
System actors bypass the rules
system() sets accessMode: "system" and a { kind: "system" } principal.
Use it to arrange state, then switch to a user actor for the assertion.
await testApp
.system()
.run(({ app, context }) =>
app.collections.posts.create({ title: "Seeded", slug: "seeded" }, context),
);
const visible = await viewer.run(({ app, context }) =>
app.collections.posts.findOne({ where: { slug: "seeded" } }, context),
);Field-level access shows the split. Give a field access: { read: false }. The
system actor reads its value. The user actor gets a row where that key is
absent, not null.
Typing a hand-built app
Codegen writes the session type onto createAppForRuntime, so the actors on a
generated app type themselves. An app you assemble yourself, such as one inside
a module's own test suite, has no such marker. Annotate the factory and the
same inference follows.
import { createApp } from "questpie/app";
import type { GeneratedAppFactory } from "@questpie/testing";
const createAppForRuntime: GeneratedAppFactory<MyApp, MySession> = async (
runtime,
) => (await createApp(definition, runtime)) as unknown as MyApp;MySession is the { user, session } pair your auth config produces. It drives
the seed shape, the { session } input and context.session in every callback.
Related
- Testing for the harness that hands out these actors.
- Context for every other key on
context. - Access control for the rules an actor exercises.
Plugins
A codegen plugin teaches the generator a new file convention, a new builder method or a whole new generated file. This is the contract, field by field.
Disposable PostgreSQL
A leased database per run, named so it can be swept, locked so a live run is never dropped, and force-dropped when you are done.