> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vertz.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# E2E Testing

> Set up Playwright tests with authenticated users, test databases, and isolated test contexts

End-to-end tests verify your entire Vertz app — server, database, auth, and UI — from the user's perspective. This guide covers setting up Playwright, authenticating test users programmatically, and managing test databases.

<Note>
  This page covers **browser-based E2E tests** with Playwright. For unit and integration tests, see
  the [Test Runner](/guides/testing-unit) guide. For type-safe server tests without a browser, see
  [Server Testing](/guides/testing-server).
</Note>

## Prerequisites

Install Playwright and its browser binaries:

```bash theme={null}
vtz add -d @playwright/test
vtzx playwright install chromium
```

## Playwright configuration

Create `playwright.config.ts` at your project root:

```ts theme={null}
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 1,
  workers: process.env.CI ? 1 : undefined,
  reporter: process.env.CI ? 'github' : 'list',

  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    headless: true,
  },

  // Playwright starts your dev server automatically
  webServer: {
    command: 'vtz dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 30_000,
  },

  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
```

The `webServer` option starts your dev server before tests run and shuts it down after. In local development, `reuseExistingServer` skips startup if the server is already running.

## Database setup

For E2E tests, use a **file-based SQLite** database. Each dev server run creates its own database file, and tests run against that instance.

```ts theme={null}
// src/api/db.ts
import { Database } from 'vertz:sqlite';
import { createDb } from '@vertz/db';
import { authModels } from '@vertz/server';

const sqlite = new Database('./data/app.db');
sqlite.exec('PRAGMA journal_mode=WAL');
sqlite.exec('PRAGMA foreign_keys=ON');

export const db = createDb({
  models: {
    ...authModels, // auth_users, sessions, oauth_accounts, etc.
    // ... your app models
  },
  dialect: 'sqlite',
  d1: createBunD1(sqlite), // D1-compatible wrapper
});
```

`authModels` provides the table definitions Vertz needs for sessions, users, and OAuth accounts. When you call `app.initialize()`, auth tables are created automatically.

### Test isolation

Each test creates a **unique user** (via a timestamped email), so tests don't interfere with each other. For seed data, insert it once on server startup:

```ts theme={null}
// src/api/seed.ts
import { db } from './db';

export async function seedDatabase() {
  // Only seed if the table is empty
  const result = await db.projects.count();
  if (result.ok && result.data > 0) return;

  await db.projects.createMany({
    data: [
      { id: 'proj-1', name: 'Engineering', key: 'ENG' },
      { id: 'proj-2', name: 'Design', key: 'DES' },
    ],
  });
}
```

To reset the database between test runs, delete the file and restart:

```bash theme={null}
rm -f ./data/app.db && vtz dev
```

## Authenticating test users

Vertz auth uses `HttpOnly` cookies, so you can't set tokens from browser JavaScript. Instead, call the auth endpoints from your test setup and pass the cookies to Playwright's browser context.

### Enable email/password auth

The `emailPassword: {}` option in `createAuth()` enables the `POST /api/auth/signup` and `POST /api/auth/signin` endpoints. This is the simplest way to create test users programmatically:

```ts theme={null}
// src/api/server.ts
import { createServer } from '@vertz/server';

export const app = createServer({
  basePath: '/api',
  entities: [
    /* ... */
  ],
  db,
  auth: {
    session: { strategy: 'jwt', ttl: '15m', cookie: { secure: false } },
    emailPassword: {}, // Enables signup/signin endpoints
    // RS256 key pair — auto-generated in dev mode, required in production
  },
});
```

<Note>
  Set `cookie: { secure: false }` for local development and testing. Without this, cookies won't be sent over plain HTTP.
</Note>

### Cookie extraction helper

Auth responses include `Set-Cookie` headers. Parse them into the format Playwright expects:

```ts theme={null}
// e2e/helpers.ts
export function extractCookies(res: Response, baseURL: string) {
  const cookies: { name: string; value: string; domain: string; path: string }[] = [];
  const url = new URL(baseURL);

  for (const header of res.headers.getSetCookie()) {
    const [nameValue] = header.split(';');
    const eqIdx = nameValue.indexOf('=');
    if (eqIdx > 0) {
      cookies.push({
        name: nameValue.slice(0, eqIdx),
        value: nameValue.slice(eqIdx + 1),
        domain: url.hostname,
        path: '/',
      });
    }
  }

  return cookies;
}
```

### Authenticate helper

Sign up a unique test user and return cookies ready for Playwright:

```ts theme={null}
// e2e/helpers.ts
export async function authenticate(baseURL: string) {
  const email = `e2e-${Date.now()}@test.local`;
  const password = 'TestPassword123!';

  const res = await fetch(`${baseURL}/api/auth/signup`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-VTZ-Request': '1',
    },
    body: JSON.stringify({ email, password }),
  });

  if (!res.ok) {
    throw new Error(`Auth signup failed: ${res.status} ${await res.text()}`);
  }

  return extractCookies(res, baseURL);
}
```

The `X-VTZ-Request: 1` header is required — Vertz uses it for CSRF protection on mutation endpoints.

### Using it in tests

Call `authenticate()` in `beforeEach` and add cookies to the browser context:

```ts theme={null}
// e2e/app.spec.ts
import { expect, test } from '@playwright/test';
import { authenticate } from './helpers';

test.describe('My App', () => {
  test.beforeEach(async ({ context, baseURL }) => {
    const url = baseURL ?? 'http://localhost:3000';
    const cookies = await authenticate(url);
    await context.addCookies(cookies);
  });

  test('authenticated page loads', async ({ page }) => {
    await page.goto('/dashboard');
    await expect(page.getByText('Welcome')).toBeVisible({ timeout: 10_000 });
  });
});
```

Every test gets a fresh user with a unique email, so tests are fully isolated.

## Multi-tenant apps

If your app uses tenant scoping, the session JWT needs a `tenantId`. After signup, call the `switch-tenant` endpoint:

```ts theme={null}
// e2e/helpers.ts
export async function authenticateWithTenant(baseURL: string, tenantId: string) {
  const email = `e2e-${Date.now()}@test.local`;
  const password = 'TestPassword123!';

  // 1. Sign up — session has no tenantId yet
  const signupRes = await fetch(`${baseURL}/api/auth/signup`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-VTZ-Request': '1' },
    body: JSON.stringify({ email, password }),
  });

  if (!signupRes.ok) {
    throw new Error(`Signup failed: ${signupRes.status} ${await signupRes.text()}`);
  }

  const signupCookies = extractCookies(signupRes, baseURL);

  // 2. Switch tenant — session now includes tenantId in JWT
  const cookieHeader = signupCookies.map((c) => `${c.name}=${c.value}`).join('; ');
  const switchRes = await fetch(`${baseURL}/api/auth/switch-tenant`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-VTZ-Request': '1',
      Cookie: cookieHeader,
    },
    body: JSON.stringify({ tenantId }),
  });

  if (!switchRes.ok) {
    throw new Error(`Switch tenant failed: ${switchRes.status} ${await switchRes.text()}`);
  }

  // Return cookies from switch-tenant (they contain the updated JWT)
  return extractCookies(switchRes, baseURL);
}
```

Use it in tests:

```ts theme={null}
test.beforeEach(async ({ context, baseURL }) => {
  const url = baseURL ?? 'http://localhost:3000';
  const cookies = await authenticateWithTenant(url, 'ws-acme');
  await context.addCookies(cookies);
});
```

Tenant switching requires the `tenant.verifyMembership` callback in your server config. For testing, you can accept all tenants or check against a seed list.

## Writing tests

### Wait for data, not timers

Use Playwright's built-in assertions with timeouts instead of `page.waitForTimeout()`:

```ts theme={null}
// Wait for an element to appear after data loads
await expect(page.getByTestId('project-list')).toBeVisible({ timeout: 10_000 });

// Wait for a specific count
await expect(page.getByTestId('project-card')).toHaveCount(3);
```

### Use unique data per test

Timestamp your test data to avoid collisions:

```ts theme={null}
test('create item appears in list', async ({ page }) => {
  const title = `E2E Item ${Date.now()}`;

  await page.goto('/items/new');
  await page.locator('#title').fill(title);
  await page.getByRole('button', { name: 'Create' }).click();

  await expect(page.getByText(title)).toBeVisible({ timeout: 10_000 });
});
```

### Use `data-testid` for stable selectors

Prefer `data-testid` attributes over CSS classes or text content for elements that tests interact with:

```tsx theme={null}
<div data-testid="project-card">{project.name}</div>
```

```ts theme={null}
await expect(page.getByTestId('project-card').first()).toBeVisible();
```

## Running tests

Add scripts to your `package.json`:

```json theme={null}
{
  "scripts": {
    "e2e": "npx playwright test",
    "e2e:headed": "npx playwright test --headed"
  }
}
```

```bash theme={null}
vtz run e2e              # Run all E2E tests (headless)
vtz run e2e:headed       # Run with browser visible (for debugging)
vtz run e2e -- --ui      # Open Playwright's interactive UI
```

In CI, tests run headless with a single worker for stability. The `webServer` config ensures the dev server starts fresh.

## Next steps

<CardGroup cols={2}>
  <Card title="Test Runner" icon="play" href="/guides/testing-unit">
    Built-in test runner with Vitest-compatible API, watch mode, and coverage.
  </Card>

  <Card title="Server Testing" icon="flask-vial" href="/guides/testing-server">
    Type-safe test client for entity CRUD, service actions, and raw HTTP requests.
  </Card>

  <Card title="Authentication" icon="lock" href="/guides/server/auth">
    Full auth configuration — sessions, stores, rate limiting, and access control.
  </Card>

  <Card title="Entities" icon="database" href="/guides/server/entities">
    Define entities with models, access rules, and hooks.
  </Card>
</CardGroup>
