Skip to content

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.

Terminal window
bun run services:up # Postgres, once
bun run test # everything
bun run --cwd apps/api test test/projects.e2e.test.ts # one file

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

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

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.

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 403 for 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.

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.

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.

Terminal window
bun run acceptance # with personal accounts
bun run acceptance --teams # with teams on
bun run acceptance --keep # and leave the app running at http://localhost:8080
bun run acceptance --orm prisma # the API on Prisma, or typeorm
bun run acceptance --polar # Polar's stand-in instead of Stripe's
bun run acceptance -- journeys/billing.spec.ts

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

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() and signUpWithWorkspace(): 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) from src/mailpit.ts.
  • For teams only, test.skip(!TEAMS, …) at the top, as journeys/teams.spec.ts does.

Leave to the API tests what a browser adds nothing to: isolation, every role, duplicate webhooks.

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.

Terminal window
bun run acceptance:mobile # the iOS simulator, personal accounts
bun run acceptance:mobile --teams # with teams on
bun run acceptance:mobile --android # an Android emulator
bun run acceptance:mobile -- mobile/account.yaml

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

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.