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 (< 10 routes)" — Landing page, auth, a few feature pages
- "Medium (10-30 routes)" — Multiple feature areas, settings, profiles
- "Large (30+ routes)" — Complex app with many sections and user roles
Round 2: Authentication strategy for tests
Question: "How does your app handle authentication?" Header: "Auth type" Options:
- "Cookie/session based (Recommended)" — Server sets httpOnly cookies after login
- "JWT in localStorage" — Token stored in browser localStorage
- "OAuth/SSO (Google, GitHub, etc.)" — Third-party auth provider redirect flow
- "No auth (public app)" — No login required
Question: "How should tests authenticate?" Header: "Test auth" Options:
- "Login via UI once, reuse state (Recommended)" — storageState pattern: login in setup, share cookies across tests
- "API login in beforeEach" — Call auth API directly before each test, skip UI login
- "Seed auth token in fixtures" — Inject pre-generated tokens, no login flow needed
- "Test login UI every time" — Actually test the login form in each test suite
Round 3: Test data and environment
Question: "How should test data be managed?" Header: "Test data" Options:
- "API seeding in fixtures (Recommended)" — Call API endpoints to create/clean test data before each test
- "Database seeding (direct SQL)" — Run SQL scripts or ORM commands to populate test database
- "Shared test environment (pre-populated)" — Tests run against a persistent staging environment with existing data
- "Mock API responses" — Intercept network requests and return mock data
Question: "What environment do E2E tests run against?" Header: "Environment" Options:
- "Local dev server (Recommended)" — Start dev server before tests, run against localhost
- "Preview/staging URL" — Run against a deployed preview or staging environment
- "Docker Compose stack" — Full stack in containers, tests run outside or inside
Round 4: CI and parallelization
Question: "How should tests run in CI?" Header: "CI" Options:
- "GitHub Actions (Recommended)" — Native Playwright support with sharding
- "GitLab CI" — Docker-based runners with Playwright image
- "Local only (no CI yet)" — Just local test runs for now
- "Other CI (Jenkins, CircleCI)" — Custom CI configuration
Question: "Do you need visual regression testing?" Header: "Visual" Options:
- "No — functional tests only (Recommended)" — Assert behavior, not pixels
- "Yes — screenshot comparisons" — Capture and compare page screenshots
- "Yes — component screenshots" — Capture specific components, not full pages
Phase 3: Plan (ExitPlanMode)
Write a concrete implementation plan covering:
-
Directory structure — test files, page objects, fixtures, config
-
Playwright config — projects (browsers), base URL, retries, workers
-
Auth setup — global setup for storageState or API-based auth
-
Page objects — classes for each page with locators and actions
-
Test fixtures — custom fixtures for data seeding, auth, API client
-
Test suites — test files for each critical flow from the interview
-
CI config — workflow file with sharding, artifact upload, reporting
Present via ExitPlanMode for user approval.
Phase 4: Execute
After approval, implement following this order:
Step 1: Playwright config
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: process.env.CI ? [['html', { open: 'never' }], ['github']] : [['html', { open: 'on-failure' }]],
use: { baseURL: process.env.BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'on-first-retry', },
projects: [ // Auth setup — runs before all tests { name: 'setup', testMatch: /.*.setup.ts/, }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: 'e2e/.auth/user.json', }, dependencies: ['setup'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'], storageState: 'e2e/.auth/user.json', }, dependencies: ['setup'], }, { name: 'mobile', use: { ...devices['iPhone 14'], storageState: 'e2e/.auth/user.json', }, dependencies: ['setup'], }, ],
webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120_000, }, });
Step 2: Auth setup (global)
// e2e/auth.setup.ts import { test as setup, expect } from '@playwright/test';
const authFile = 'e2e/.auth/user.json';
setup('authenticate', async ({ page }) => { // Navigate to login page await page.goto('/login');
// Fill login form await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL || 'test@example.com'); 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
// 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<string, unknown>) {
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<Fixtures>({ 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
// 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
// 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('test@example.com', 'testpassword'); await expect(page).toHaveURL('/dashboard'); });
test('invalid credentials shows error', async ({ loginPage }) => { await loginPage.goto(); await loginPage.login('test@example.com', '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)
// 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
.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:
// Preferred — accessible and resilient await page.getByRole('button', { name: 'Submit' }).click(); await page.getByLabel('Email').fill('user@test.com');
// 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('user@test.com');
Wait for network, not timers
Never use page.waitForTimeout() . Wait for specific conditions:
// 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:
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
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