E2E Testing with Playwright
Run and write end-to-end tests with Playwright. Covers test execution, Page Objects, bootstrap helpers, and debugging flaky tests.
End-to-end tests live in apps/e2e and use Playwright to test complete user flows against a real browser and database.
Playwright E2E tests verify that authentication, navigation, forms, and business logic work correctly together. Tests run against seeded test data in a real PostgreSQL database, catching issues that mocks would miss. For testing isolated business logic, use unit tests with Vitest instead.
Running Tests
Production Build (Recommended)
For reliable results, run tests against a production-like build:
# Terminal 1: Build and start the apppnpm --filter web build:testpnpm --filter web start:test# Terminal 2: Run tests with 2 workerspnpm --filter web-e2e test:slowThe test:slow command runs with 2 workers and stops on first failure. This reduces flakiness from race conditions and resource contention.
Fast Iteration (Single Test)
When developing or debugging a specific test:
pnpm --filter web-e2e exec playwright test auth.spec.ts --workers=1You can also run tests by partial name:
# Runs any spec matching "billing"pnpm --filter web-e2e exec playwright test billing --workers=1Full Suite
Run all tests (rare, usually for CI):
pnpm --filter web-e2e testInteractive UI Mode
Debug tests with Playwright's visual interface:
pnpm --filter web-e2e test:uiThis opens a browser where you can step through tests, inspect elements, and see traces.
Test Structure
Tests are organized by feature in apps/e2e/tests/:
tests/├── auth/│ ├── auth.spec.ts # Sign up, sign in, password reset, MFA│ └── auth.po.ts # Page Object for auth├── account/│ └── account.spec.ts # Account management├── members/│ └── members.spec.ts # Team member management├── invitations/│ └── invitations.spec.ts├── roles/│ └── roles.spec.ts # Custom roles├── settings/│ ├── settings.spec.ts│ ├── profile-picture.spec.ts│ └── organization-settings.spec.ts├── admin/│ ├── admin-users.spec.ts│ ├── admin-organizations.spec.ts│ └── impersonation.spec.ts├── utils/│ ├── bootstrap-helpers.ts│ └── test-data.po.ts├── mailbox.po.ts # Email testing helper└── global.setup.ts # Seeds database before testsPage Object Pattern
Every feature uses a Page Object that encapsulates selectors and actions. This keeps tests readable and maintainable.
export class AuthPageObject { constructor(private readonly page: Page) {} async goToSignIn() { await this.page.goto('/auth/sign-in'); } async signIn(params: { email: string; password: string }) { await this.page.getByTestId('email-input').fill(params.email); await this.page.locator('input[name="password"]').fill(params.password); await this.page.getByTestId('auth-submit-button').click(); } async expectToBeSignedIn() { await expect( this.page.getByTestId('account-switcher-trigger') ).toBeVisible(); }}Tests use the Page Object:
test('should sign in with valid credentials', async ({ page }) => { const auth = new AuthPageObject(page); await auth.goToSignIn(); await auth.signIn({ email: 'test@example.com', password: 'Password123!' }); await auth.expectToBeSignedIn();});Bootstrap Helpers
Creating users through the UI is slow. Bootstrap helpers create users directly in the database and log in via API:
import { bootstrapAuthenticatedUser } from '../utils/bootstrap-helpers';test('should update profile settings', async ({ page }) => { // Creates user in DB and logs in via API (no UI) const { user, auth } = await bootstrapAuthenticatedUser(page); // User is already logged in and on /dashboard await page.goto('/settings'); // ... rest of test});Available bootstrap helpers:
| Helper | Description |
|---|---|
bootstrapAuthenticatedUser | Creates and logs in a user |
bootstrapUserWithOrg | Creates user + organization, switches to org context |
bootstrapOrgWithMembers | Creates org with multiple members, logged in as owner |
bootstrapOrgMember | Creates org, logged in as a regular member (not owner) |
bootstrapSuperAdminUser | Creates and logs in a super admin |
Example with organization:
test('owner can remove members', async ({ page }) => { const { org, auth } = await bootstrapOrgWithMembers(page, { memberEmails: ['member1@test.com', 'member2@test.com'] }); // Logged in as owner, in org context await page.goto(`/home/${org.slug}/members`); // ... test member removal});Environment Variables
Configure test behavior in apps/e2e/.env.local:
| Variable | Default | Description |
|---|---|---|
ENABLE_TEAM_ACCOUNT_TESTS | true | Include team/organization specs |
PLAYWRIGHT_SERVER_COMMAND | - | If set, Playwright starts the server automatically |
To disable team account tests (for personal-account-only mode):
ENABLE_TEAM_ACCOUNT_TESTS=false pnpm --filter web-e2e test:slowConfiguration Reference
Key settings in apps/e2e/playwright.config.ts:
export default defineConfig({ testDir: './tests', globalSetup: './tests/global.setup.ts', // Seeds database fullyParallel: true, retries: 2, timeout: 120 * 1000, // 2 minutes per test use: { baseURL: 'http://localhost:3000', screenshot: 'only-on-failure', trace: 'on-first-retry', testIdAttribute: 'data-testid', },});Global Setup
Before tests run, global.setup.ts seeds the database:
async function globalSetup() { await execAsync('pnpm --filter scripts seed', { cwd: process.cwd(), env: process.env, });}This ensures consistent test data. The seed script creates base users and data needed for tests.
Debugging Flaky Tests
View Test Reports
After a test run, open the HTML report:
pnpm --filter web-e2e reportCapture Traces
Traces are captured on first retry by default. View them in the report or:
pnpm --filter web-e2e exec playwright show-trace trace.zipCommon Flakiness Causes
- Race conditions: Use
await expect(...).toPass()for polling assertions - Animation timing: Wait for elements with
waitForSelector - Database state: Ensure tests don't depend on each other's data
- Resource contention: Reduce workers with
--workers=1
Example: Polling Assertion
Instead of:
// Might fail if email takes time to arriveconst result = await mailbox.getEmail(email);expect(result).not.toBeNull();Use:
// Retries until passing or timeoutawait expect(async () => { const result = await mailbox.getEmail(email); expect(result).not.toBeNull();}).toPass({ timeout: 10_000 });CI Configuration
For GitHub Actions or similar CI:
- name: Run E2E tests run: | pnpm --filter web build:test pnpm --filter web start:test & sleep 10 pnpm --filter web-e2e test env: CI: trueThe CI environment variable:
- Limits workers to 2 (configured in playwright.config.ts)
- Fails on
test.only(prevents accidental commits) - Enables stricter error handling
Available Test Commands
# Standard run (stops on first failure)pnpm --filter web-e2e test# Slow run with 2 workers (most reliable)pnpm --filter web-e2e test:slow# Fast run with 8 workers (when tests are stable)pnpm --filter web-e2e test:fast# Interactive UIpnpm --filter web-e2e test:ui# View last reportpnpm --filter web-e2e reportFrequently Asked Questions
How do I run E2E tests in headed mode to see the browser?
How do I debug a failing E2E test?
What's the difference between test:slow and test:fast?
How do bootstrap helpers speed up tests?
Can I run E2E tests against a production build?
How do I test email verification flows?
Next Steps
- Unit Testing with Vitest for testing business logic in isolation
- Writing Your Own Tests for patterns and best practices