[ PROMPT_NODE_25025 ]
Playwright E2e Builder
[ SKILL_DOCUMENTATION ]
# Playwright E2E Test Suite Builder
## When to use
Use this skill when you need to:
- Set up Playwright from scratch in an existing project
- Build E2E tests for critical user flows (signup, checkout, dashboards)
- Implement Page Object Model for maintainable test architecture
- Configure authentication state persistence across tests
- Set up visual regression testing with screenshots
- Integrate Playwright into CI/CD with sharding and retries
## Phase 1: Explore (Plan Mode)
Enter plan mode. Before writing any tests, explore the existing project:
### Project structure
- Find the tech stack: is this React, Next.js, Vue, SvelteKit, or another framework?
- Check if Playwright is already installed (`playwright.config.ts`, `@playwright/test` in package.json)
- Look for existing test directories (`e2e/`, `tests/`, `__tests__/`)
- Check for existing E2E tests in Cypress, Selenium, or other frameworks (migration context)
- Find the dev server command and port (`npm run dev`, `next dev`, etc.)
### Application structure
- Identify the main routes/pages (look at router config, pages directory, or route files)
- Find authentication flow (login page URL, auth API endpoints, token storage)
- Check for test IDs in components (`data-testid`, `data-test`, `data-cy` attributes)
- Look for API routes that tests might need to seed data through
- Check `.env` files for test-specific environment variables
### CI/CD
- Check for existing CI config (`.github/workflows/`, `.gitlab-ci.yml`, `Jenkinsfile`)
- Look for Docker or docker-compose setup (useful for consistent test environments)
- Check if there's a staging/preview environment URL pattern
## Phase 2: Interview (AskUserQuestion)
Use AskUserQuestion to clarify requirements. Ask in rounds.
### Round 1: Scope and critical flows
```
Question: "What are the critical user flows to test?"
Header: "Flows"
multiSelect: true
Options:
- "Authentication (signup, login, logout, password reset)" — Core auth flows
- "Core CRUD (create, read, update, delete main resources)" — Primary data operations
- "Checkout/payments (cart, billing, confirmation)" — E-commerce or payment flows
- "Dashboard/admin (data views, filters, exports)" — Admin panel interactions
```
```
Question: "How many pages/routes does the application have approximately?"
Header: "App size"
Options:
- "Small ( {
// Navigate to login page
await page.goto('/login');
// Fill login form
await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL || '[email protected]');
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD || 'testpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for auth to complete — adjust selector to your app
await page.waitForURL('/dashboard');
await expect(page.getByRole('navigation')).toBeVisible();
// Save signed-in state
await page.context().storageState({ path: authFile });
});
```
### Step 3: Custom fixtures
```typescript
// e2e/fixtures.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from './pages/login-page';
import { DashboardPage } from './pages/dashboard-page';
// API client for test data seeding
class ApiClient {
constructor(private baseURL: string, private token?: string) {}
async createResource(data: Record) {
const response = await fetch(`${this.baseURL}/api/resources`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error(`Seed failed: ${response.status}`);
return response.json();
}
async deleteResource(id: string) {
await fetch(`${this.baseURL}/api/resources/${id}`, {
method: 'DELETE',
headers: this.token ? { Authorization: `Bearer ${this.token}` } : {},
});
}
}
type Fixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
api: ApiClient;
};
export const test = base.extend({
loginPage: async ({ page }, use) => {
await use(new LoginPage(page));
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
api: async ({ baseURL }, use) => {
const client = new ApiClient(baseURL!);
await use(client);
},
});
export { expect };
```
### Step 4: Page Object Model
```typescript
// e2e/pages/login-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class LoginPage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly submitButton: Locator;
readonly errorMessage: Locator;
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorMessage = page.getByRole('alert');
}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.submitButton.click();
}
async expectError(message: string) {
await expect(this.errorMessage).toContainText(message);
}
}
// e2e/pages/dashboard-page.ts
import { type Page, type Locator, expect } from '@playwright/test';
export class DashboardPage {
readonly heading: Locator;
readonly createButton: Locator;
readonly searchInput: Locator;
readonly resourceList: Locator;
constructor(private page: Page) {
this.heading = page.getByRole('heading', { level: 1 });
this.createButton = page.getByRole('button', { name: 'Create' });
this.searchInput = page.getByPlaceholder('Search');
this.resourceList = page.getByTestId('resource-list');
}
async goto() {
await this.page.goto('/dashboard');
}
async createResource(name: string) {
await this.createButton.click();
await this.page.getByLabel('Name').fill(name);
await this.page.getByRole('button', { name: 'Save' }).click();
}
async search(query: string) {
await this.searchInput.fill(query);
// Wait for debounced search to trigger
await this.page.waitForResponse(resp =>
resp.url().includes('/api/resources') && resp.status() === 200
);
}
async expectResourceVisible(name: string) {
await expect(this.resourceList.getByText(name)).toBeVisible();
}
async expectResourceCount(count: number) {
await expect(this.resourceList.getByRole('listitem')).toHaveCount(count);
}
}
```
### Step 5: Test suites
```typescript
// e2e/auth.spec.ts
import { test, expect } from './fixtures';
test.describe('Authentication', () => {
// These tests run WITHOUT storageState (unauthenticated)
test.use({ storageState: { cookies: [], origins: [] } });
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
await loginPage.goto();
await loginPage.login('[email protected]', 'testpassword');
await expect(page).toHaveURL('/dashboard');
});
test('invalid credentials shows error', async ({ loginPage }) => {
await loginPage.goto();
await loginPage.login('[email protected]', 'wrongpassword');
await loginPage.expectError('Invalid credentials');
});
test('logout clears session', async ({ page }) => {
// Login first
await page.goto('/login');
// ... login steps ...
// Logout
await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL('/login');
// Verify can't access protected route
await page.goto('/dashboard');
await expect(page).toHaveURL('/login');
});
});
// e2e/dashboard.spec.ts
import { test, expect } from './fixtures';
test.describe('Dashboard', () => {
test('displays resource list', async ({ dashboardPage }) => {
await dashboardPage.goto();
await expect(dashboardPage.heading).toHaveText('Dashboard');
await expect(dashboardPage.resourceList).toBeVisible();
});
test('create new resource', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.createResource('New E2E Resource');
// Verify resource appears in list
await dashboardPage.expectResourceVisible('New E2E Resource');
});
test('search filters results', async ({ dashboardPage, api }) => {
// Seed test data via API
await api.createResource({ name: 'Alpha Item' });
await api.createResource({ name: 'Beta Item' });
await dashboardPage.goto();
await dashboardPage.search('Alpha');
await dashboardPage.expectResourceVisible('Alpha Item');
});
test('empty state shown when no resources', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
await dashboardPage.search('nonexistent-query-xyz');
await expect(page.getByText('No results found')).toBeVisible();
});
});
// e2e/crud.spec.ts
import { test, expect } from './fixtures';
test.describe('Resource CRUD', () => {
let resourceId: string;
test.beforeEach(async ({ api }) => {
// Seed a resource for tests that need one
const resource = await api.createResource({ name: 'Test Resource' });
resourceId = resource.id;
});
test.afterEach(async ({ api }) => {
// Clean up seeded data
if (resourceId) {
await api.deleteResource(resourceId).catch(() => {});
}
});
test('edit resource name', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Edit' }).click();
await page.getByLabel('Name').clear();
await page.getByLabel('Name').fill('Updated Resource');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByRole('heading')).toHaveText('Updated Resource');
});
test('delete resource with confirmation', async ({ page }) => {
await page.goto(`/resources/${resourceId}`);
await page.getByRole('button', { name: 'Delete' }).click();
// Confirm deletion dialog
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Confirm' }).click();
// Should redirect to list
await expect(page).toHaveURL('/dashboard');
});
});
```
### Step 6: Visual regression (if selected)
```typescript
// e2e/visual.spec.ts
import { test, expect } from './fixtures';
test.describe('Visual regression', () => {
test('dashboard matches snapshot', async ({ dashboardPage, page }) => {
await dashboardPage.goto();
// Wait for dynamic content to stabilize
await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixelRatio: 0.01,
});
});
test('login page matches snapshot', async ({ loginPage, page }) => {
test.use({ storageState: { cookies: [], origins: [] } });
await loginPage.goto();
await expect(page).toHaveScreenshot('login.png', {
maxDiffPixelRatio: 0.01,
});
});
// Component-level screenshots
test('navigation component matches snapshot', async ({ page }) => {
await page.goto('/dashboard');
const nav = page.getByRole('navigation');
await expect(nav).toHaveScreenshot('navigation.png');
});
});
```
### Step 7: GitHub Actions CI
```yaml
# .github/workflows/e2e.yml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
e2e:
timeout-minutes: 30
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Install Playwright browsers
run: npx playwright install --with-deps chromium
- name: Run E2E tests
run: npx playwright test --shard=${{ matrix.shard }}
env:
BASE_URL: http://localhost:3000
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- name: Upload test report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-${{ strategy.job-index }}
path: playwright-report/
retention-days: 14
- name: Upload test results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results-${{ strategy.job-index }}
path: test-results/
retention-days: 7
```
## Directory structure reference
```
e2e/
├── .auth/
│ └── user.json # Saved auth state (gitignored)
├── fixtures.ts # Custom test fixtures and API client
├── pages/
│ ├── login-page.ts # Login page object
│ ├── dashboard-page.ts # Dashboard page object
│ └── resource-page.ts # Resource detail page object
├── auth.setup.ts # Global auth setup (runs once)
├── auth.spec.ts # Authentication tests
├── dashboard.spec.ts # Dashboard tests
├── crud.spec.ts # CRUD operation tests
└── visual.spec.ts # Visual regression tests (optional)
playwright.config.ts # Playwright configuration
```
## Best practices
### Use role-based locators first
Prefer `getByRole()`, `getByLabel()`, `getByText()` over CSS selectors or test IDs. These locators mirror how users interact with the page and catch accessibility issues:
```typescript
// Preferred — accessible and resilient
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByLabel('Email').fill('[email protected]');
// Fallback — when role-based doesn't work
await page.getByTestId('custom-widget').click();
// Avoid — fragile, breaks on refactors
await page.locator('.btn-primary').click();
await page.locator('#email-input').fill('[email protected]');
```
### Wait for network, not timers
Never use `page.waitForTimeout()`. Wait for specific conditions:
```typescript
// Wait for API response
await page.waitForResponse(resp => resp.url().includes('/api/data'));
// Wait for element state
await expect(page.getByText('Saved')).toBeVisible();
// Wait for navigation
await expect(page).toHaveURL('/dashboard');
// Wait for loading to finish
await expect(page.getByTestId('spinner')).toBeHidden();
```
### Isolate test data
Each test should create its own data and clean up after:
```typescript
test('edit resource', async ({ api, page }) => {
// Arrange — seed via API
const resource = await api.createResource({ name: 'Test' });
// Act
await page.goto(`/resources/${resource.id}`);
// ... test logic ...
// Cleanup (also runs on failure via afterEach)
});
```
### Tag tests for selective runs
```typescript
test('checkout flow @slow @checkout', async ({ page }) => {
// Long test tagged for selective execution
});
// Run only: npx playwright test --grep @checkout
// Skip slow: npx playwright test --grep-invert @slow
```
### .gitignore additions
```
# Playwright
e2e/.auth/
test-results/
playwright-report/
blob-report/
```
## Checklist before finishing
- [ ] `playwright.config.ts` has webServer configured to start the dev server
- [ ] Auth setup saves storageState and all test projects depend on it
- [ ] Page objects use role-based locators (`getByRole`, `getByLabel`, `getByText`)
- [ ] No `waitForTimeout()` calls — only wait for elements, URLs, or responses
- [ ] Tests create and clean up their own data (no shared mutable state)
- [ ] CI config has sharding for parallel execution
- [ ] Trace, screenshot, and video are captured on failure for debugging
- [ ] `.auth/` directory is in `.gitignore`
- [ ] `npx playwright test` passes locally before pushing
Source: claude-code-templates (MIT). See About Us for full credits.