Skip to main content
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.

Prerequisites

Install Playwright and its browser binaries:
bun add -d @playwright/test
bunx playwright install chromium

Playwright configuration

Create playwright.config.ts at your project root:
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: 'bun run 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.
// src/api/db.ts
import { Database } from 'bun: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:
// 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:
rm -f ./data/app.db && bun run 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:
// 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
  },
});
Set cookie: { secure: false } for local development and testing. Without this, cookies won’t be sent over plain HTTP.
Auth responses include Set-Cookie headers. Parse them into the format Playwright expects:
// 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:
// 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:
// 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:
// 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:
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():
// 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:
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:
<div data-testid="project-card">{project.name}</div>
await expect(page.getByTestId('project-card').first()).toBeVisible();

Running tests

Add scripts to your package.json:
{
  "scripts": {
    "e2e": "npx playwright test",
    "e2e:headed": "npx playwright test --headed"
  }
}
bun run e2e              # Run all E2E tests (headless)
bun run e2e:headed       # Run with browser visible (for debugging)
bun 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

Server Testing

Type-safe test client for entity CRUD, service actions, and raw HTTP requests.

Authentication

Full auth configuration — sessions, stores, rate limiting, and access control.

Entities

Define entities with models, access rules, and hooks.

Client Auth

AuthProvider, useAuth(), and sign-in forms.