Reusable, battle-tested template for Playwright end-to-end testing. Works with any web framework — Next.js, Vite, Remix, Nuxt, SvelteKit, and more.
Built from real-world experience testing a production Next.js 16 app with 38 pages, 60+ API routes, 5 roles, and 132 test cases.
- Role-based auth — Login once per role in global setup, reuse across all tests
- Retry-resilient auth — 3-attempt retry logic for flaky server cold starts
- SSR-aware — Wait utilities that handle hydration timing in SSR frameworks
- Framework-agnostic — Config supports Next.js, Vite, Remix, Nuxt, SvelteKit, CRA
- Battle-tested patterns — Fixes for
.or()strict mode, SSR blank pages, concurrency issues - CI/CD ready — GitHub Actions workflow included, GitLab CI example in docs
- Markdown reporter — Optional custom reporter for Markdown test summaries
# 1. Copy to your project
cd your-project
bash /path/to/Playwright-E2E-Template/setup.sh
# 2. Configure (3 files to edit)
# e2e/fixtures/test-data.ts → users, routes, credentials
# e2e/helpers/auth.ts → login page selectors
# e2e/playwright.config.ts → port, build command
# 3. Run tests
npm run test:e2e# Install
npm install -D @playwright/test # or pnpm / yarn / bun
npx playwright install chromium
# Copy template
cp -r Playwright-E2E-Template/e2e/ your-project/e2e/
# Add to package.json scripts:
# "test:e2e": "npx playwright test --config=e2e/playwright.config.ts"e2e/
├── playwright.config.ts # Config: port, workers, timeouts, webServer
├── global-setup.ts # Pre-authenticates all roles (with retry)
│
├── fixtures/
│ ├── test-data.ts # Users, routes, constants (EDIT THIS FIRST)
│ └── base.fixture.ts # Extended test fixture with role pages
│
├── helpers/
│ ├── auth.ts # Login/logout helpers (EDIT FOR YOUR APP)
│ ├── selectors.ts # Centralized DOM selectors
│ ├── navigation.ts # SPA navigation with SSR waits
│ ├── form.ts # Form fill, select, upload, submit
│ ├── wait.ts # Loading, skeleton, toast, SSR waiters
│ ├── api.ts # Direct API call helpers
│ └── markdown-reporter.ts # Custom Markdown report generator
│
├── auth/ # Saved auth states (gitignored)
│ └── *.json
│
└── tests/
├── _example-auth.spec.ts # Login, logout, session, redirect
├── _example-crud.spec.ts # List, create, edit, delete
├── _example-form.spec.ts # Validation, submit, cancel
├── _example-navigation.spec.ts # Sidebar, heading, header
└── _example-rbac.spec.ts # Role-based access control
| File | What to Change |
|---|---|
e2e/fixtures/test-data.ts |
User credentials, roles, app routes |
e2e/helpers/auth.ts |
Login page selectors, post-login redirect URL |
e2e/playwright.config.ts |
Port, build + start command |
| Variable | Default | Description |
|---|---|---|
E2E_PORT |
3000 |
App port |
E2E_BASE_URL |
http://localhost:3000 |
Full base URL |
E2E_SERVER_CMD |
npm run build && npm run start |
Build + serve command |
CI |
— | Set automatically in CI; reduces workers, increases retries |
| Framework | Port | E2E_SERVER_CMD |
|---|---|---|
| Next.js | 3000 | npm run build && npm run start |
| Vite | 4173 | npm run build && npm run preview |
| Remix | 3000 | npm run build && npm run start |
| Nuxt 3 | 3000 | npm run build && npm run preview |
| SvelteKit | 4173 | npm run build && npm run preview |
| CRA | 3000 | npm run build && npx serve -s build |
Global Setup (runs once before all tests)
├── Launch browser
├── For each role in USERS:
│ ├── Open login page
│ ├── Fill email + password
│ ├── Click submit
│ ├── Wait for redirect
│ ├── Save cookies to e2e/auth/{role}.json
│ └── Retry up to 3 times on failure
└── Close browser
Tests (run in parallel)
├── test.use({ storageState: USERS.admin.storageState })
│ → Browser loads with admin's saved cookies
│ → No login needed — test starts on any page
└── ...
Option A: Per-describe block (recommended)
test.describe('Admin features', () => {
test.use({ storageState: USERS.admin.storageState });
test('should see admin panel', async ({ page }) => {
await page.goto('/admin');
// page is already authenticated as admin
});
});Option B: Named fixture pages (for multi-role tests)
test('admin and user see different data', async ({ adminPage, userPage }) => {
await adminPage.goto('/dashboard');
await userPage.goto('/dashboard');
// Each page has its own authenticated context
});Copy _example-*.spec.ts files and rename:
cp e2e/tests/_example-crud.spec.ts e2e/tests/products.spec.tsimport { test, expect } from '../fixtures/base.fixture';
import { USERS, ROUTES } from '../fixtures/test-data';
import { waitForPageLoad, waitForToast } from '../helpers/wait';
import { navigateTo } from '../helpers/navigation';
test.describe('Products', () => {
test.use({ storageState: USERS.admin.storageState });
test('should display product list', async ({ page }) => {
await navigateTo(page, '/products');
await waitForPageLoad(page);
// Assert content is visible
const table = page.locator('table').first();
await expect(table).toBeVisible();
});
test('should create product', async ({ page }) => {
await navigateTo(page, '/products/new');
await page.getByLabel('Name').fill('Test Product');
await page.getByLabel('Price').fill('99.99');
await page.locator('button[type="submit"]').click();
await waitForToast(page, /success|created/i);
});
});For tests that depend on data that might not exist:
test('should edit item', async ({ page }) => {
await navigateTo(page, '/items');
const editBtn = page.getByRole('button', { name: /edit/i }).first();
if (!(await editBtn.isVisible().catch(() => false))) {
test.skip(true, 'No items available to edit');
return;
}
await editBtn.click();
// ... continue test
});Add to your package.json:
{
"scripts": {
"test:e2e": "npx playwright test --config=e2e/playwright.config.ts",
"test:e2e:ui": "npx playwright test --config=e2e/playwright.config.ts --ui",
"test:e2e:headed": "npx playwright test --config=e2e/playwright.config.ts --headed",
"test:e2e:debug": "npx playwright test --config=e2e/playwright.config.ts --debug",
"test:e2e:report": "npx playwright show-report playwright-report"
}
}# Single file
npm run test:e2e -- auth
# By grep pattern
npm run test:e2e -- -g "should login"
# Headed (see the browser)
npm run test:e2e:headed
# Debug mode (step through)
npm run test:e2e:debug
# UI mode (interactive)
npm run test:e2e:ui// Prefer (most resilient → least resilient):
page.getByRole("button", { name: "Save" }) // ARIA role
page.getByLabel("Email") // Form label
page.getByTestId("submit-btn") // data-testid
page.locator('button[type="submit"]') // CSS (last resort)// BAD — throws if both match
await expect(page.locator("h1").or(page.locator("main"))).toBeVisible();
// GOOD — always add .first()
const hasHeading = await page.locator("h1").first().isVisible().catch(() => false);
const hasMain = await page.locator("main").first().isVisible().catch(() => false);
expect(hasHeading || hasMain).toBeTruthy();// BAD — state accumulates across runs
const name = currentName + " Updated"; // → "Name Updated Updated Updated..."
// GOOD — fixed value, same result every run
const name = "Test Company";// Always use networkidle for SSR frameworks
await page.goto("/dashboard", { waitUntil: "networkidle" });
// Wait for main content (handles slow hydration)
await page.locator("main").first()
.waitFor({ state: "visible", timeout: 20_000 })
.catch(() => {});Add these entries:
/playwright-report/
/test-results/
/e2e/auth/*.json
| Guide | Description |
|---|---|
| docs/ADAPTING.md | Step-by-step adaptation for different frameworks, auth systems, UI libraries |
| docs/TROUBLESHOOTING.md | Common issues and solutions from real-world usage |
| docs/CI-CD.md | GitHub Actions, GitLab CI setup with examples |
A GitHub Actions workflow is included at .github/workflows/e2e.yml. Copy it to your project:
cp -r Playwright-E2E-Template/.github/ your-project/.github/See docs/CI-CD.md for GitLab CI and other platforms.
MIT