461 lines
14 KiB
Markdown
461 lines
14 KiB
Markdown
---
|
|
name: playwright-e2e
|
|
description: 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.ts`
|
|
- `e2e/auth-flow.unauth.spec.ts`
|
|
- `e2e/crud-flow.spec.ts` (template)
|
|
- `e2e/navigation.spec.ts` (template)
|
|
- `e2e/accessibility.spec.ts`
|
|
- `docker-compose.test.yml`
|
|
- `scripts/seed.sql`
|
|
- Updated `package.json` scripts
|
|
- Updated `.gitignore`
|
|
|
|
---
|
|
|
|
## Workflow
|
|
|
|
### Step 1: Install dependencies
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```typescript
|
|
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:**
|
|
- `setup` project runs `auth.setup.ts` first — logs in once, saves session
|
|
- `authenticated` project reuses saved `storageState` — no repeated logins
|
|
- `unauthenticated` project has no auth state — tests public pages and login flows
|
|
|
|
### Step 3: Create `e2e/auth.setup.ts`
|
|
|
|
```typescript
|
|
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`:
|
|
|
|
```yaml
|
|
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`
|
|
|
|
```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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```json
|
|
{
|
|
"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:**
|
|
```bash
|
|
echo -n "test123" | htpasswd -bnBC 10 "" - | cut -d: -f2
|
|
```
|
|
|
|
### 2. waitForURL — use globs, not broad regex
|
|
|
|
```typescript
|
|
// ❌ 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.
|
|
|
|
```typescript
|
|
// ❌ 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:
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```typescript
|
|
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 |
|