Test your features
The kit’s tests boot the real API against a real Postgres and call it over HTTP, the way the web app does. There are 303 of them, and they run in about 15 seconds. Yours go beside them and use the same helpers.
Run them
Section titled “Run them”bun run services:up # Postgres, oncebun run test # everythingbun run --cwd apps/api test test/projects.e2e.test.ts # one fileThey use their own database, yours with _test on the end, created on the first run and
migrated on every run. Whatever is in .env, they run with Stripe, Polar, AI, OAuth, admin emails and
your mail provider turned off, so they never reach a real service and pass on any machine.
Start the app in a test
Section titled “Start the app in a test”const t = await createTestApp({ teams: true });const olive = await t.signUp({ name: 'Olive', email: 'olive@example.com' });
const res = await olive.get('/api/me');createTestApp empties the database and starts the real app on a free port. Call
t.close() in afterAll.
| Option | What it does |
|---|---|
teams |
Run as teams or as personal accounts. Test both if your code differs |
plans |
Other pricing for this test, so it passes when yours changes |
stripe |
Turns billing on, with FakeStripe standing in |
polar |
Turns billing on with Polar, with FakePolar standing in |
model |
Turns AI on, with a mock model standing in |
admins |
The addresses ADMIN_EMAILS would list |
contactEmail |
Turns the contact form on |
controllers, providers |
Extra routes or job handlers, to test a guard or a job of your own |
People
Section titled “People”| Helper | What you get |
|---|---|
t.signUp({ name, email }) |
Someone signed up and verified, as a client that keeps its cookie |
t.addMember(owner, workspaceId, { name, email, role }) |
Someone invited who accepted, as their own client |
t.client() |
A browser nobody is signed in to |
A client has get, post, postOk and request. Parse answers with the schema from
packages/contracts, as the apps do.
Stand-ins
Section titled “Stand-ins”Email. Nothing is sent. Every email lands in t.outbox: emails() lists them,
latestTo(address, subject) finds one, and linkTo(address, subject) gives you the link in
it. See Send email.
Stripe. FakeStripe keeps customers and subscriptions in memory and remembers what your
code asked for in checkouts, portals and seatUpdates. completeCheckout() and
invoicePaid() make the events Stripe would send, and t.webhook(event) delivers them with a
real signature. Pass { secret: 'wrong' } to test a forged one. See
Charge for a feature.
Polar. FakePolar does the same for Polar: checkoutsStarted, portals, seatUpdates and
revoked record what your code asked for, completeCheckout() makes the subscription and its
event, and t.polarWebhook(event) delivers it signed as Polar signs. It starts with a product
for every plan, as after billing:sync.
AI. Pass a MockLanguageModelV4 from ai/test as model. See
AI features.
Jobs. Nothing runs in the background. Queued jobs run when the test calls
JobsService.drain(), and reading the outbox does that for you. See
Background jobs.
What to test for a new feature
Section titled “What to test for a new feature”The tests that matter are the ones a customer would be hurt by:
- Someone in another workspace gets
404, for every route. - Each role can do what it should and gets
403for the rest. - Invalid input gets
400. - A plan without the feature gets
402.
Add a resource has all four written out. The skills make your coding agent write them too.
What is already covered
Section titled “What is already covered”| Area | What the tests prove |
|---|---|
| Sign-in | Sign-up, verification, magic links, password reset, account deletion |
| Workspaces | Isolation between workspaces, roles, invitations, seat limits, both modes |
| Billing | Checkout, webhooks that arrive twice or forged, plan changes, per-seat pricing |
| Usage | Allowances, the 402, only overage reported, once, and records at the same time |
| Running without | The app works with no payment provider key and no AI key |
| Jobs | Retries, giving up, schedules, running once across workers |
| Admin | Who gets in, signing in as someone, suspension |
| Audit log | What is recorded, who can read it, actions by support |
| Logs | Secrets stay out of request lines |
| Deployment | The Railway definition and every deploy:check |
Pure logic, such as pricing sums and retry delays, has unit tests next to the code, named
*.test.ts.
Browser journeys
Section titled “Browser journeys”The API tests prove the API. The journeys in packages/acceptance prove the product works for
a person, in a browser: Playwright clicking through the production images, Caddy included, with
email read from Mailpit.
bun run acceptance # with personal accountsbun run acceptance --teams # with teams onbun run acceptance --keep # and leave the app running at http://localhost:8080bun run acceptance --orm prisma # the API on Prisma, or typeormbun run acceptance --polar # Polar's stand-in instead of Stripe'sbun run acceptance -- journeys/billing.spec.tsIt builds and starts the images with docker-compose.acceptance.yml, runs every journey, and
stops them. The first time, install the browser: bun x playwright install chromium in
packages/acceptance. With --teams it turns teams on in packages/contracts/src/app.ts while
the images build, and puts the file back afterwards. CI runs both modes on Drizzle, and teams on Prisma, on TypeORM and with Polar,
and keeps the trace and a screenshot of a journey that fails.
STAND_INS=true in that file replaces Stripe and the model inside the app: checkout is a page
of the app’s own, with a Pay button, that sends the webhook Stripe would; the portal has
Cancel subscription; paying sends the receipt as Stripe’s first invoice does; the model
answers the same sentence every time, a word every half second when the message says “Take your
time.”, so a journey can press Stop or leave in the middle. Google and GitHub are
replaced too: their sign-in page is a page of the app’s own, where the journey types a name and
an address, and the API’s calls to them are answered without leaving the machine
(apps/api/src/stand-ins/oauth.ts). The demo banner is on, linking back to the site. Error reports go to a stand-in for Sentry in the API
(/api/stand-ins/sentry/reports has them), and traces to a Jaeger container, at
http://localhost:16686 while the stack runs, so a journey can check what was reported and that
nothing private was. Better Auth’s sign-in
limits are off, since the journeys sign people up side by side. It is refused unless WEB_URL
is this machine, or beside a real key, and deploy:check fails if it is on.
Write one
Section titled “Write one”Put it in packages/acceptance/journeys/<area>.spec.ts, one per feature, the way someone would
use it:
import { expect, test } from '@playwright/test';import { person, signUpWithWorkspace } from '../src/people.js';
test('adding a project, and finding it in the list', async ({ page }) => { await signUpWithWorkspace(page, person()); await page.getByRole('link', { name: 'Projects' }).click(); await page.getByLabel('Name').fill('Engine'); await page.getByRole('button', { name: 'Add project' }).click(); await expect(page.getByRole('listitem').filter({ hasText: 'Engine' })).toBeVisible();});- Find things the way a person does: by role and label (
getByRole,getByLabel), never by CSS class or test id. A journey then survives a new design. - Test the flow, not the words around it. Changing the overview must not break signing up.
- Each journey makes its own people and workspace with
person()andsignUpWithWorkspace(): they run side by side, against one database. - Wait for the page you expect before typing into it: a heading, with
expect(...).toBeVisible(). - Read the email someone was sent with
linkIn(email, subject)fromsrc/mailpit.ts. - For teams only,
test.skip(!TEAMS, …)at the top, asjourneys/teams.spec.tsdoes.
Leave to the API tests what a browser adds nothing to: isolation, every role, duplicate webhooks.
Mobile flows
Section titled “Mobile flows”The mobile app’s own flows run with Maestro against the same production images and stand-ins: signing up and verifying, the account screens, the assistant once paid for, and with teams on, accepting an invitation and switching workspace.
bun run acceptance:mobile # the iOS simulator, personal accountsbun run acceptance:mobile --teams # with teams onbun run acceptance:mobile --android # an Android emulatorbun run acceptance:mobile -- mobile/account.yamlIt builds a release of the app in the mode asked for, pointed at the stack, installs it on a
running simulator or emulator or starts one, and runs every flow in packages/acceptance/mobile.
It needs Maestro (brew install mobile-dev-inc/tap/maestro), and Xcode and CocoaPods for iOS or
the Android SDK with an emulator for Android. The first build takes a while; later ones reuse it.
--build-only builds the app and stops; --no-build runs the flows on the app already installed.
CI uses the two so the emulator is not left idle during a long build, which Android cuts off.
CI runs the Android flows when you start .github/workflows/mobile-flows.yml by hand, from the
Actions tab or with gh workflow run mobile-flows.yml. iOS needs a Mac, and GitHub’s Macs cannot run the
stack’s containers: run it on yours before each release.
Write one
Section titled “Write one”Put it in packages/acceptance/mobile/<area>.yaml. Start from a person made through the API
(scripts/signed-up.js), sign in through the app, and open screens by their link
(subflows/open.yaml, saasstack://account) rather than by tapping the tab bar, which moves while
the app learns who is signed in. Find things by what they say, as with the browser journeys. Tag
a flow teams when it only makes sense with teams on. Before typing in a screen just opened, wait
for it (assertVisible its title, then waitForAnimationToEnd): Android drops text typed while
it slides in. If typing starts failing in every flow, cold-boot the emulator.