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

For reliable results, run tests against a production-like build:

# Terminal 1: Build and start the app
pnpm --filter web build:test
pnpm --filter web start:test
# Terminal 2: Run tests with 2 workers
pnpm --filter web-e2e test:slow

The 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=1

You can also run tests by partial name:

# Runs any spec matching "billing"
pnpm --filter web-e2e exec playwright test billing --workers=1

Full Suite

Run all tests (rare, usually for CI):

pnpm --filter web-e2e test

Interactive UI Mode

Debug tests with Playwright's visual interface:

pnpm --filter web-e2e test:ui

This 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 tests

Page 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:

HelperDescription
bootstrapAuthenticatedUserCreates and logs in a user
bootstrapUserWithOrgCreates user + organization, switches to org context
bootstrapOrgWithMembersCreates org with multiple members, logged in as owner
bootstrapOrgMemberCreates org, logged in as a regular member (not owner)
bootstrapSuperAdminUserCreates 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:

VariableDefaultDescription
ENABLE_TEAM_ACCOUNT_TESTStrueInclude 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:slow

Configuration 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 report

Capture Traces

Traces are captured on first retry by default. View them in the report or:

pnpm --filter web-e2e exec playwright show-trace trace.zip

Common Flakiness Causes

  1. Race conditions: Use await expect(...).toPass() for polling assertions
  2. Animation timing: Wait for elements with waitForSelector
  3. Database state: Ensure tests don't depend on each other's data
  4. Resource contention: Reduce workers with --workers=1

Example: Polling Assertion

Instead of:

// Might fail if email takes time to arrive
const result = await mailbox.getEmail(email);
expect(result).not.toBeNull();

Use:

// Retries until passing or timeout
await 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: true

The 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 UI
pnpm --filter web-e2e test:ui
# View last report
pnpm --filter web-e2e report

Frequently Asked Questions

How do I run E2E tests in headed mode to see the browser?
Use pnpm --filter web-e2e exec playwright test --headed. You can also use test:ui for the interactive UI mode which provides step-through debugging.
How do I debug a failing E2E test?
Run pnpm --filter web-e2e report to view the HTML report with screenshots and traces. Traces are captured on first retry and show every action, network request, and DOM snapshot.
What's the difference between test:slow and test:fast?
test:slow runs with 2 workers for reliability, test:fast runs with 8 workers for speed. Use test:slow when tests are flaky or during CI, test:fast when your test suite is stable.
How do bootstrap helpers speed up tests?
Bootstrap helpers create users and organizations directly in the database and log in via API, skipping the UI entirely. This makes test setup nearly instant compared to clicking through sign-up forms.
Can I run E2E tests against a production build?
Yes, and it's recommended. Use pnpm --filter web build:test && pnpm --filter web start:test to build and serve a production-like build, then run your tests against it.
How do I test email verification flows?
MakerKit uses Mailpit to capture emails locally. The Mailbox helper in tests fetches verification links from Mailpit and navigates to them automatically.

Next Steps