14 KiB
name, description
| name | description |
|---|---|
| playwright-e2e | Set up standardized Playwright E2E test infrastructure for Next.js + Spring Boot projects. Creates 3-project config (setup/authenticated/unauthenticated), auth flow, Docker test environment, accessibility tests, and seed data. Use when asked to set up Playwright tests, create E2E test infrastructure, add Playwright to a project, or create an integration test harness. |
Playwright E2E Test Infrastructure
When to use
- Setting up Playwright E2E tests for a Next.js + backend project
- Creating test infrastructure from scratch
- Adding authentication-aware E2E testing
- Setting up accessibility testing with axe-core
- Triggers: "set up Playwright tests", "create E2E test infrastructure", "add Playwright to this project", "E2E test suite", "create integration test harness"
When NOT to use
- Unit testing (use Jest/Vitest instead)
- API-only testing without a frontend (use Postman/REST client)
- Projects without a web frontend
- Adding individual test files to an existing Playwright setup (just write the test directly)
Required Inputs
| Input | Source | Example |
|---|---|---|
PROJECT_DIR |
Current workspace | /Users/pplate/git/personal/inspectflow |
FRONTEND_DIR |
Relative path to frontend | frontend/ |
BASE_URL |
Dev server URL | http://localhost:3000 |
API_URL |
Backend URL | http://localhost:8080 |
LOGIN_EMAIL |
Test user email | admin@test.de |
LOGIN_PASSWORD |
Test user password | test123 |
Expected Output
playwright.config.ts(3-project architecture)e2e/auth.setup.tse2e/auth-flow.unauth.spec.tse2e/crud-flow.spec.ts(template)e2e/navigation.spec.ts(template)e2e/accessibility.spec.tsdocker-compose.test.ymlscripts/seed.sql- Updated
package.jsonscripts - Updated
.gitignore
Workflow
Step 1: Install dependencies
cd <FRONTEND_DIR>
pnpm add -D @playwright/test @axe-core/playwright
npx playwright install chromium
Step 2: Create playwright.config.ts
Place in <FRONTEND_DIR>/playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html'], ['list']],
use: {
baseURL: process.env.BASE_URL || '<BASE_URL>',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/,
},
{
name: 'authenticated',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/admin.json',
},
dependencies: ['setup'],
testIgnore: /.*\.unauth\.spec\.ts/,
},
{
name: 'unauthenticated',
use: { ...devices['Desktop Chrome'] },
testMatch: /.*\.unauth\.spec\.ts/,
},
],
});
Key architecture:
setupproject runsauth.setup.tsfirst — logs in once, saves sessionauthenticatedproject reuses savedstorageState— no repeated loginsunauthenticatedproject has no auth state — tests public pages and login flows
Step 3: Create e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = '.auth/admin.json';
setup('authenticate', async ({ page }) => {
const email = process.env.TEST_EMAIL || '<LOGIN_EMAIL>';
const password = process.env.TEST_PASSWORD || '<LOGIN_PASSWORD>';
await page.goto('/login');
await page.getByLabel('E-Mail').fill(email);
await page.getByLabel('Passwort').fill(password);
await page.getByRole('button', { name: /anmelden|login|sign in/i }).click();
// Wait for successful redirect to dashboard
await page.waitForURL('**/dashboard');
await expect(page.locator('body')).not.toContainText('Login');
// Save authentication state
await page.context().storageState({ path: authFile });
});
Step 4: Create docker-compose.test.yml
Place in <PROJECT_DIR>/docker-compose.test.yml:
services:
test-db:
image: postgres:17-alpine
environment:
POSTGRES_DB: testdb
POSTGRES_USER: test
POSTGRES_PASSWORD: test
ports:
- "5433:5432"
volumes:
- ./scripts/seed.sql:/docker-entrypoint-initdb.d/01-seed.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U test -d testdb"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
environment:
SPRING_PROFILES_ACTIVE: test
SPRING_DATASOURCE_URL: jdbc:postgresql://test-db:5432/testdb
SPRING_DATASOURCE_USERNAME: test
SPRING_DATASOURCE_PASSWORD: test
JWT_SECRET: test-secret-key-for-e2e-testing-only
ports:
- "8080:8080"
depends_on:
test-db:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 10s
timeout: 5s
retries: 10
Step 5: Create scripts/seed.sql
-- E2E Test Seed Data
-- Password "test123" as BCrypt hash
-- Verify with: echo -n "test123" | htpasswd -bnBC 10 "" - | cut -d: -f2
INSERT INTO users (email, password_hash, name, role, active)
VALUES (
'<LOGIN_EMAIL>',
'$2a$10$/FgU1KyveJ7MaQ7Xv4kxD.5EIQUHujJfZI4K2E1H7pS6parMHJpeG',
'Test Admin',
'ADMIN',
true
) ON CONFLICT (email) DO NOTHING;
-- Sample data (adapt to your schema)
-- INSERT INTO companies (name, created_by) VALUES ('Test GmbH', 1);
⚠️ CRITICAL: The BCrypt hash $2a$10$/FgU1KyveJ7MaQ7Xv4kxD.5EIQUHujJfZI4K2E1H7pS6parMHJpeG is for the password test123. If you change the password, regenerate the hash. This was the #1 source of broken tests in CannaManage.
Step 6: Create test file templates
e2e/auth-flow.unauth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Authentication Flow', () => {
test('login page loads correctly', async ({ page }) => {
await page.goto('/login');
await expect(page.getByRole('heading', { name: /anmelden|login/i })).toBeVisible();
await expect(page.getByLabel('E-Mail')).toBeVisible();
await expect(page.getByLabel('Passwort')).toBeVisible();
});
test('shows validation errors for empty form', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: /anmelden|login|sign in/i }).click();
// Expect validation messages
await expect(page.locator('text=/required|pflichtfeld|eingeben/i')).toBeVisible();
});
test('redirects unauthenticated users to login', async ({ page }) => {
await page.goto('/dashboard');
await page.waitForURL('**/login**');
});
test('registration page loads', async ({ page }) => {
await page.goto('/register');
await expect(page.getByRole('heading', { name: /registr/i })).toBeVisible();
});
});
e2e/crud-flow.spec.ts
import { test, expect } from '@playwright/test';
test.describe('CRUD Operations', () => {
test('can navigate to list page', async ({ page }) => {
await page.goto('/dashboard');
// Adapt: click sidebar link to your main entity
// await page.getByRole('link', { name: 'Companies' }).click();
// await expect(page.getByRole('heading', { name: 'Companies' })).toBeVisible();
});
test('can create a new item', async ({ page }) => {
// Adapt: navigate to creation form
// await page.goto('/companies/new');
// await page.getByLabel('Name').fill('Test Company');
// await page.getByRole('button', { name: /erstellen|create|save/i }).click();
// await expect(page.locator('text=/erfolgreich|created|success/i')).toBeVisible();
});
test('can edit an existing item', async ({ page }) => {
// Adapt: navigate to edit form
// await page.goto('/companies');
// await page.getByRole('row').first().getByRole('link', { name: /edit|bearbeiten/i }).click();
// await page.getByLabel('Name').fill('Updated Company');
// await page.getByRole('button', { name: /speichern|save|update/i }).click();
// await expect(page.locator('text=/aktualisiert|updated|success/i')).toBeVisible();
});
test('can delete an item', async ({ page }) => {
// Adapt: delete flow with confirmation dialog
// await page.goto('/companies');
// await page.getByRole('row').first().getByRole('button', { name: /löschen|delete/i }).click();
// await page.getByRole('dialog').getByRole('button', { name: /bestätigen|confirm|delete/i }).click();
// await expect(page.locator('text=/gelöscht|deleted|success/i')).toBeVisible();
});
});
e2e/navigation.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Navigation', () => {
test('sidebar navigation works', async ({ page }) => {
await page.goto('/dashboard');
// Adapt: check your sidebar links exist
const sidebar = page.locator('[data-sidebar]');
await expect(sidebar).toBeVisible();
});
test('breadcrumbs display correctly', async ({ page }) => {
await page.goto('/dashboard');
// Adapt: verify breadcrumb trail
// await expect(page.locator('nav[aria-label="breadcrumb"]')).toBeVisible();
});
test('mobile menu toggle works', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('/dashboard');
// Adapt: check mobile menu behavior
// await page.getByRole('button', { name: /menu/i }).click();
// await expect(page.locator('[data-sidebar]')).toBeVisible();
});
});
e2e/accessibility.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
const pages = [
{ name: 'Dashboard', path: '/dashboard' },
// Add all pages to scan:
// { name: 'Companies', path: '/companies' },
// { name: 'New Company', path: '/companies/new' },
];
test.describe('Accessibility', () => {
for (const { name, path } of pages) {
test(`${name} has no accessibility violations`, async ({ page }) => {
await page.goto(path);
await page.waitForLoadState('networkidle');
const results = await new AxeBuilder({ page }).analyze();
// Log violations for debugging
if (results.violations.length > 0) {
console.log(`Accessibility violations on ${name}:`);
results.violations.forEach((v) => {
console.log(` [${v.impact}] ${v.id}: ${v.description}`);
v.nodes.forEach((n) => console.log(` ${n.html}`));
});
}
expect(results.violations).toEqual([]);
});
}
});
Step 7: Add package.json scripts
Add to <FRONTEND_DIR>/package.json scripts section:
{
"scripts": {
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:headed": "playwright test --headed",
"e2e:report": "playwright show-report"
}
}
Step 8: Update .gitignore
Append to <FRONTEND_DIR>/.gitignore:
# Playwright
.auth/
playwright-report/
test-results/
Lessons Learned & Gotchas
1. BCrypt hash MUST match the password
The #1 cause of broken E2E suites. If seed.sql has a hash that doesn't match the password in auth.setup.ts, all authenticated tests fail silently (login form rejects credentials).
Verify with:
echo -n "test123" | htpasswd -bnBC 10 "" - | cut -d: -f2
2. waitForURL — use globs, not broad regex
// ❌ BAD — matches too broadly, causes flaky tests
await page.waitForURL(/dashboard|\//)
// ✅ GOOD — specific glob pattern
await page.waitForURL('**/dashboard')
3. shadcn/ui selectors
Don't use generic CSS class selectors for shadcn components. They use dynamic Tailwind classes.
// ❌ BAD
page.locator('.sidebar')
// ✅ GOOD
page.locator('[data-sidebar]') // sidebar
page.locator('[role="dialog"]') // modals/dialogs
page.getByRole('button', { name: 'Save' }) // buttons
page.getByLabel('Email') // form fields
4. Console error filtering
Tests fail on unexpected console errors. Filter known/expected ones:
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error' && !msg.text().includes('expected-error-pattern')) {
errors.push(msg.text());
}
});
// ... do test actions ...
expect(errors).toEqual([]);
5. Auth state caching = 10x speed
The setup project runs ONCE per test run. All authenticated tests reuse the saved .auth/admin.json session cookie. Never log in per-test unless testing auth specifically.
6. Accessibility scanning pattern
import AxeBuilder from '@axe-core/playwright';
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
Run on every page. Add new pages to the pages array in accessibility.spec.ts.
7. Visual regression (optional, add when ready)
await expect(page).toHaveScreenshot('page-name.png', { maxDiffPixels: 100 });
First run creates baseline screenshots. Subsequent runs compare. Commit screenshots to git.
8. Docker test environment
Backend should have a test Spring profile that:
- Uses the test PostgreSQL (port 5433 to avoid conflicts)
- Runs Flyway migrations + seed on startup
- Has shorter JWT expiry for faster test cycles
- Disables rate limiting and email sending
Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| All auth tests fail | Wrong BCrypt hash in seed.sql | Regenerate hash for your password |
waitForURL times out |
URL doesn't match pattern | Use page.url() to print actual URL, adjust pattern |
| Tests pass locally, fail in CI | Missing npx playwright install |
Add to CI setup step |
storageState file not found |
.auth/ directory doesn't exist |
Create .auth/ dir or let setup create it |
| Flaky navigation tests | Page not fully loaded | Add await page.waitForLoadState('networkidle') |
| axe violations on shadcn | Missing aria labels | Add aria-label to interactive elements |