# E2E Testing with Playwright in MakerKit

> Run and write end-to-end tests with Playwright for your TanStack Start MakerKit application. Covers Page Object pattern, bootstrap helpers, and debugging flaky tests.

*Canonical: https://makerkit.dev/docs/tanstack-drizzle/testing/e2e*

---

End-to-end (E2E) tests in MakerKit live in `apps/e2e` and use Playwright to test complete user flows against a real browser and PostgreSQL database.

Playwright E2E tests verify that authentication flows, navigation, forms, and business logic work correctly together in your TanStack Start application. Tests run against seeded test data in a real database with Drizzle ORM, catching issues that mocks would miss like constraint violations and session handling. For testing isolated business logic without a browser, use [unit tests with Vitest](./unit) instead.

## Running Tests

### Production Build (Recommended)

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

```bash
# 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:

```bash
pnpm --filter web-e2e exec playwright test auth.spec.ts --workers=1
```

You can also run tests by partial name:

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

### Full Suite

Run all tests (rare, usually for CI):

```bash
pnpm --filter web-e2e test
```

### Interactive UI Mode

Debug tests with Playwright's visual interface:

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

```typescript title="apps/e2e/tests/auth/auth.po.ts"
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:

```typescript title="apps/e2e/tests/auth/auth.spec.ts"
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:

```typescript title="apps/e2e/tests/settings/settings.spec.ts"
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:

```typescript
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 (active org tracked via cookie, no slug in the path)
  await page.goto('/settings/members');
  // ... test member removal
});
```

## Environment Variables

Configure test behavior in `apps/e2e/.env` (the committed defaults). You can override values locally with `apps/e2e/.env.local` if present:

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

```bash
ENABLE_TEAM_ACCOUNT_TESTS=false pnpm --filter web-e2e test:slow
```

## Configuration Reference

Key settings in `apps/e2e/playwright.config.ts`:

```typescript
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:

```typescript title="apps/e2e/tests/global.setup.ts"
import { seed } from '../../../tooling/scripts/src/seed';

async function globalSetup() {
  // Seeds the database with test data
  await seed();
}

export default globalSetup;
```

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:

```bash
pnpm --filter web-e2e report
```

### Capture Traces

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

```bash
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:

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

Use:

```typescript
// 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:

```yaml
- 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

```bash
# 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
```

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "How do I run Playwright tests in headed mode to see the browser?", "answer": "Use pnpm --filter web-e2e exec playwright test --headed to see the browser. You can also use test:ui for Playwright's interactive UI mode which provides step-through debugging and DOM inspection."},
     {"question": "How do I debug a failing E2E test?", "answer": "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 for debugging."},
     {"question": "What's the difference between test:slow and test:fast?", "answer": "test:slow runs with 2 workers for reliability and stops on first failure. 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."},
     {"question": "How do bootstrap helpers speed up Playwright tests?", "answer": "Bootstrap helpers create users and organizations directly in the database via Better Auth API, skipping the UI entirely. This makes test setup nearly instant compared to clicking through sign-up forms."},
     {"question": "Can I run E2E tests against a production build?", "answer": "Yes, and it's recommended for catching production-only issues. 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."},
     {"question": "How do I test email verification flows in Playwright?", "answer": "MakerKit uses Mailpit to capture emails locally during development and testing. The Mailbox Page Object helper fetches verification links from Mailpit's API and navigates to them automatically."},
     {"question": "Why are my Playwright tests flaky?", "answer": "Common causes include race conditions, animation timing, and test data conflicts. Run with --workers=1 to isolate timing issues, use expect().toPass() for polling assertions, and ensure each test creates its own data with bootstrap helpers."}
   ]
/%}

## Next Steps

- [Unit Testing with Vitest](./unit) for testing business logic in isolation
- [Writing Your Own Tests](./writing-tests) for patterns and best practices
