Skip to content
On this page

Testing

Where Teak's tests live, which layer to use, the rules that make a test worth keeping, and how to run the end-to-end suite

How to write tests in Teak that fail when behavior breaks, and pass when it does not. Read this before adding, changing, or deleting a test.

The one rule

A test earns its place only if it fails when the behavior it names is broken. Before you finish, ask: “If I deleted or broke the code under test, would this test fail?” If not, it is not a test. Delete it or rewrite it.

Pick the layer

What changed Test with Where it lives Run it
Pure function (parsers, formatters, URL rules) bun test the workspace’s test directory (table below) bun test <path>
Convex query, mutation, action, or workflow step logic bun test with the helpers in packages/convex/__tests__/helpers/ packages/convex/__tests__/<mirrors source path> bun run --cwd packages/convex test:unit
Convex behavior that depends on the real database, auth sessions, or components convex-test on Vitest (edge runtime) packages/convex/edge/*.test.ts (packages/convex/vitest.config.ts runs every file there) bun run --cwd packages/convex test:edge
Files Worker Workflow behavior only the real engine decides (retries, a NonRetryableError ending a step, what reaches run() after a step fails) bun test with Wrangler’s createTestHarness (Miniflare’s Workflows engine) and a local server standing in for Convex apps/files-worker/src/**, like src/workflows/cardProcessing/engine.test.ts bun test <path> in apps/files-worker
Web React UI (components, hooks, screens, lib) bun test + renderToStaticMarkup a __tests__ folder beside the code, e.g. apps/web/src/components/cards/__tests__/ bun run --cwd apps/web test
A real browser flow or a cross-surface journey (web, API, CLI, MCP) e2e against the local stack and the WorkOS emulator packages/tests/src/web/ for web surfaces, packages/tests/src/journey/ for journeys bun run --cwd packages/tests e2e (End-to-end suite)

Prefer the lowest layer that can observe the behavior. Move up a layer only when the behavior lives in the integration: a query index, an auth session, a browser API.

Where files go

Each workspace script runs only the files it matches. A test file outside these paths never runs, and that is a bug.

Workspace Test files
packages/convex __tests__/**/*.test.ts (Bun). edge/**/*.test.ts (Vitest, convex-test), whose module map is import.meta.glob("../**/*.ts") so function paths stay rooted at packages/convex. Don’t colocate Bun tests next to source files.
apps/web src/**/*.test.{ts,tsx} (Bun, one process per file). Tests sit in the __tests__ folder beside the code they cover; src/__tests__ holds route, next.config.ts and cross-cutting wiring tests.
packages/tests src/**/*.e2e.ts (e2e; each e2e*.config.ts names the directories it runs), src/**/*.test.ts (Bun)
apps/cli, apps/files-worker, packages/files-protocol, packages/sdk src/**
apps/raycast src/__tests__/**
apps/extension __tests__/**
packages/i18n src/__tests__/** (locales, translator, pseudo-locale). The catalog check, scripts/i18n-catalog.test.ts, runs with the repo scripts: every language has English’s keys, placeholders, plurals and tags in valid ICU.
apps/docs lib/**
apps/apple tests/*.test.ts (run by the root bun run test) and Swift Testing in Packages/TeakKit/Tests
apps/android tests/*.test.ts (run by the root bun run test) and JUnit in each module’s src/test
repo scripts scripts/**/*.test.ts, run by the root bun run test

bun run test at the root runs all of it, and the Unit Tests workflow runs it on every pull request.

Rules

Assert outcomes, not implementation.

  • Assert returned values, database state, rendered text, roles, href and src, HTTP status and body, and CLI stdout and exit codes.
  • Don’t assert that a mock was called with a query-builder chain (withIndex, eq, take). Seed data and assert the result.
  • Don’t assert CSS class names or DOM nesting.

Never test the source text. Don’t readFileSync a source file and toContain a string. Extract the logic into a function and call it. If a regression truly can’t be reached any other way, keep at most one such assertion and name the bug in a comment.

Mock only boundaries you don’t own. That means network, AI providers, R2 storage, Polar, email, time, and native modules. Never mock the module under test or its sibling modules to reach a branch.

Never re-implement the code in the test. Copying a regex list or an if chain into the test and asserting on the copy proves nothing. Export the function and call it (see apps/extension/lib/restrictedUrl.ts).

Always await async assertions. await expect(p).rejects.toThrow(). An unawaited .rejects or .resolves passes even when the promise does the wrong thing.

No filler. None of these count as tests:

  • expect(true).toBe(true)
  • expect(module).toBeDefined()
  • asserting a constant equals its own literal
  • building an object in the test and asserting its own fields

Keep tests hermetic.

  • Restore any process.env keys you change.
  • Clear variables that other modules set at import time. For example, next.config.ts sets NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA whenever a commit SHA is present, as it is in CI.
  • Don’t depend on test order. Check with bun test --randomize and bun test --seed=<n>.

One behavior per test, named as the behavior. Write “refuses to auto-save chrome:// pages”, not “test isValidUrl 3”. Use test.each for tables of inputs.

Browser tests:

  • Use getByRole and getByLabel locators, and web-first expect assertions. e2e matches names exactly and case-sensitively; pass { exact: false } only when a fragment is the point.
  • No agent steps (agent.act, agent.assert): the suite configures no model, so every step is a locator or an exact expect.
  • Never waitForTimeout, and never wait for networkidle.
  • Never test.skip when an element is missing. A missing element is a failure.
  • Credential-gated skips are the only allowed skip.

Data against real backends:

  • Use unique markers per run (generateTestContent in packages/tests/src/web/fixtures.ts).
  • End-to-end accounts are WorkOS emulator users that exist only for the run. Each spec file makes its own (createAccount or createApiAccount in packages/tests/src/helpers/app.ts), and web specs get one per test from packages/tests/src/web/fixtures.ts. Files share nothing, so they can run in any order.
  • Never point a test at production or staging accounts.

Reuse before you write

  • Convex:
    • packages/convex/__tests__/helpers/ provides withTestSession, R2 mocks and public API HTTP helpers.
    • packages/convex/edge/occContention.test.ts has insertCard and search sync fixtures.
    • packages/convex/edge/workosSessions.test.ts shows WorkOS-backed sessions with t.withIdentity.
  • End-to-end: packages/tests/src/helpers/app.ts (sign-up, hosted sign-in, API keys, deletion), emulator.ts (WorkOS users, emailed codes, session tokens), page-hooks.ts (page errors, blocked requests, Convex socket routing), test.ts (retry, firstVisible), api.ts, cli.ts and mcp.ts. Headless WorkOS sessions against staging: scripts/lib/workos-test-session.ts.
  • Raycast: createRaycastApiMock in apps/raycast/src/__tests__/raycastApiMock.ts.

Before you finish

  1. For a bug fix, write the failing test first and watch it fail for the right reason.
  2. Run the narrowest test, then bun run --cwd <workspace> test, then bun run test if the change crosses workspaces.
  3. Break the code on purpose (flip a condition, delete a line) and confirm the new test fails. Then restore it.
  4. Re-read the diff for skipped tests, unawaited assertions, and deleted assertions. Never delete or loosen an assertion just to make a test pass.

End-to-end suite

packages/tests holds e2e journeys across the web app, REST API, CLI and MCP server. They run against a local stack that exists only for this suite and the clean-container smoke; development always uses the shared development deployment, which the suite never touches. The stack is:

  • the official WorkOS emulator (@workos/emulate), standing in for hosted AuthKit,
  • a local Convex backend (API and webhooks on the next port),
  • the Files Worker under wrangler dev in local mode (src/stack/filesWorker.ts), which serves files and runs background work on Workflows, Queues, R2, KV and Analytics Engine as Miniflare emulates them,
  • the web app.

It needs no secrets, so you can run it without access to the development deployment. Every WorkOS value is a test-only constant in src/stack/config.ts, and the emulator keeps everything in memory; the Files Worker’s signing secret is a test-only constant too. The main checkout uses web 3000, Convex 3210/3211, the emulator 4100/4101 and the Files Worker 4102 (its inspector on 4103); each linked worktree uses its own ports.

The specs drive the browser with locators and exact expect assertions only. They run no agent steps, so the suite needs no model or API key.

Run it

From the repo root, with this checkout’s dev stack stopped (bun run dev --stop):

bun install
(cd packages/tests && bunx e2e-web install chromium webkit)   # once
bun run --cwd packages/tests e2e

e2e checks the pinned runtimes and packages, sets up the local backend (src/stack/backend.ts), starts the stack, warms it up (e2e.warmup.config.ts), runs the Chromium suite (e2e.config.ts) and the WebKit half of the browser matrix (e2e.webkit.config.ts), and stops everything. Pass e2e run arguments to run something narrower, for example bun run --cwd packages/tests e2e src/journey/03-api.e2e.ts or bun run --cwd packages/tests e2e -- --grep editor.

To iterate, keep the stack up in one terminal and run e2e in another:

bun run --cwd packages/tests e2e:stack
cd packages/tests && E2E_TELEMETRY_DISABLED=1 bunx e2e run src/web/markdown-editor.e2e.ts:42

A running stack records its URLs in .agents/.state/stack.json, which the configs and helpers read, and logs to .agents/.state/stack.log. While it runs it takes over packages/convex/.env.local; the next bun run dev selects the development deployment again. A failed test prints the path of its trace page (.e2e/<suite>/results/<test>/trace.md): every step with what it hit, the app’s console errors and failed requests, and the screen at the failure. Read it before re-running. Add --headed to watch a run, --video on to record every attempt, and --trace on to keep traces for passing tests too. --last-failed, --repeat-each=<n>, --grep, --max-failures and path:line selection also work.

The E2E workflow (.github/workflows/e2e.yml) runs the same command daily and on demand (not on pull requests or pushes), and uploads .e2e/ and the stack log. The Clean Container Smoke workflow runs src/matrix in Chromium from a clean checkout on every pull request and posts the result on it.

Suites

Each config runs its own directories and writes to its own .e2e/<suite>/:

Config Runs Against
e2e.warmup.config.ts src/warmup/: compiles the signed-in routes and loads the backend modules once the local stack
e2e.config.ts src/journey/, src/web/, src/matrix/ in Chromium the local stack
e2e.webkit.config.ts src/matrix/ in WebKit the local stack
e2e.docs.config.ts src/docs/: read-only checks of the published site and Teak’s public production endpoints (bun run --cwd packages/tests e2e:docs); the E2E workflow runs it daily production, no stack
e2e.hermetic.config.ts src/auth-runtime/ and src/extension/: the WorkOS consent and Security dialog flows and the extension’s OAuth module, with every request answered by fixtures (bun run --cwd packages/tests e2e:hermetic) nothing; TEAK_E2E_HERMETIC=1

Files in a suite run in parallel and share nothing: every spec makes its own accounts. Tests inside a file run in order on one worker, and a describe(..., { serial: true }) group shares one browser and its module state, for flows such as sign-up, sign-out and account deletion.

Directory Covers
src/journey/ Sign-up through the verification code; cards, search, filters, favorites, tags, trash and restore, bulk actions, the editor, deep links, link metadata; the REST API with OpenAPI checks, the CLI from this checkout, every public MCP tool, contention; cross-tenant access, revoked keys, hostile input, security headers, the session cookie; axe scans of / and /settings; password reset, sign-out, account deletion and dead credentials afterward
src/web/ Markdown editor, WebMCP, sign-in entry routing, settings navigation and layout, composer save shortcut, Settings → Language and the pseudo-locale
src/matrix/ Sign-up, create and search in each engine

How the stack is wired

src/stack/backend.ts points packages/convex/.env.local at a local backend and sets WORKOS_CLIENT_ID, WORKOS_API_KEY, WORKOS_API_BASE_URL, WORKOS_ENVIRONMENT_ID and WORKOS_WEBHOOK_SECRET on it. The stack passes the web app its emulator settings, including WORKOS_API_HOSTNAME, WORKOS_API_PORT and WORKOS_API_HTTPS for authkit-nextjs, as process environment, which Next.js prefers over apps/web/.env.local, so the development wiring in that file stays as it is.

The emulator runs with:

  • the interactive login pages, with the password step, like hosted AuthKit,
  • https://api.workos.com as the token issuer, which the backend requires,
  • a JWT template that adds email and email_verified to session tokens, like Teak’s WorkOS environment,
  • a webhook endpoint for user.* events at the backend’s /workos/webhook, signed with the pinned test secret.

A small proxy in front of the emulator adds hosted AuthKit’s 30-second refresh-token grace window (session resilience). The web client refreshes its session on every page load, and without the window a navigation that interrupts that refresh would end the session. The backend runs with convex dev --once --start, so the suite’s own file writes never trigger a push.

Accounts

Accounts are real WorkOS users in the emulator. Creating one through the emulator API sends a signed user.created webhook, which gives the address a Teak vault. Browser specs then sign in through the hosted login page (signIn and createAccount in src/helpers/app.ts); the sign-up journey’s account starts unverified, so its first sign-in goes through the emailed verification code. The emulator never sends email: codes and reset tokens are read from its /events API (src/helpers/emulator.ts). API keys come from Settings, the way people create them, except in the API, CLI and MCP specs, which never open a browser: createApiAccount calls the same mutation Settings does, signed in with the emulator’s password grant. Tests sign in fresh instead of reusing saved cookies, which go stale once the client refreshes its session.

What e2e doesn’t do, and the stand-ins

src/helpers/page-hooks.ts covers the Playwright features the specs need that e2e doesn’t have, with init scripts in the page:

  • capturePageErrors records uncaught errors and unhandled rejections (Playwright’s pageerror).
  • blockAndRecordRequests records and aborts requests to a URL pattern (the request event, for “never fetched” checks).
  • routeConvexSocket routes the browser’s Convex sync socket through interceptors that run in the page: a backend that can’t be reached, an injected mutation failure, a rewritten query result (routeWebSocket).

retry and firstVisible in src/helpers/test.ts stand in for expect(...).toPass() and locator.or(). Each test has one page: a second user signs in after app.clearState(), or the check runs at the API.

What the local stack doesn’t cover

  • Workers AI, Browser Run, Vectorize and the two Containers. They need a Cloudflare account or Docker. The Worker’s AI binding is a service binding to a deterministic fake (apps/files-worker/e2e/fakeAi.ts), so text and image cards get tags and summaries; link fetching, link screenshots, video and PDF rendering, audio transcripts and embeddings fail the way they do when those services are down, and their stages end failed. Uploads, image analysis (Images is emulated), exports, imports and account deletion run for real.
  • AuthKit Actions (the sign-up freeze registration action). The emulator doesn’t run Actions; packages/convex/edge/workosAuthKit.test.ts covers it.
  • WorkOS Connect OAuth for the CLI, extensions, Raycast and MCP. The emulator’s Connect flow has no PKCE or refresh tokens. The API, CLI and MCP journeys use Teak API keys, and the browser OAuth flows have hermetic checks: bun run --cwd packages/tests e2e:hermetic, and the Extension OAuth Runtime workflow.
  • Billing. Polar checkout needs Polar credentials.
  • The browser extension. It signs in with WorkOS Connect, and e2e can’t load a browser extension.

Bun test options

bun test (Bun 1.4) has options that help when a test misbehaves:

  • --changed[=<ref>] runs only test files affected by changed files.
  • --only-failures, --bail, and --rerun-each=20 confirm a flake is really fixed.
  • --randomize with --seed=<n> finds and replays order dependence.
  • --isolate gives each file a fresh global, which shows whether a failure is a leak from another file.
  • --parallel=<n>, --shard=<i>/<n>, and --coverage --coverage-reporter=lcov are also available.

Last updated on

Was this page helpful?