Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Plan and build comprehensive Playwright E2E test suites with Page Object Model, authentication state persistence, custom fixtures, visual regression, and CI integration. Uses interview-driven planning to clarify critical user flows, auth strategy, test data approach, and parallelization before writing any tests.
.claude/skills/davila7-playwright-e2e-builder/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-01 | ✗→✓ | ▲ Improved | -1% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 607% | 0% |
| case-10 | ✓→✗ | ▼ Worse | 234% | 0% |
| case-06 | ✓→✓ | = Same ✓ | 187% | 0% |
| case-07 | ✓→✓ | = Same ✓ | 231% | 0% |
Use this skill when you need to:
Enter plan mode. Before writing any tests, explore the existing project:
playwright.config.ts, @playwright/test in package.json)e2e/, tests/, __tests__/)npm run dev, next dev, etc.)data-testid, data-test, data-cy attributes).env files for test-specific environment variables.github/workflows/, .gitlab-ci.yml, Jenkinsfile)Use AskUserQuestion to clarify requirements. Ask in rounds.
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 interactionsQuestion: "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 rolesQuestion: "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 suiteQuestion: "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 insideQuestion: "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 pagesWrite a concrete implementation plan covering:
Present via ExitPlanMode for user approval.
After approval, implement following this order:
typescriptimport { 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, }, });
typescript// 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 }); });
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<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 };
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); } }
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('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'); }); });
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'); }); });
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
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 configurationPrefer 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('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');
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();
Each test should create its own data and clean up after:
typescripttest('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) });
typescripttest('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
# Playwright
e2e/.auth/
test-results/
playwright-report/
blob-report/playwright.config.ts has webServer configured to start the dev servergetByRole, getByLabel, getByText)waitForTimeout() calls — only wait for elements, URLs, or responses.auth/ directory is in .gitignorenpx playwright test passes locally before pushing| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-06 | pass→pass | 14,345 | 15,303 | +7% | 1 | 1 | 0% | 2,524 | 7,239 | +187% | 0 | 0 | — |
case-07 | pass→pass | 14,240 | 14,306 | +0% | 1 | 1 | 0% | 2,408 | 7,975 | +231% | 0 | 0 | — |
case-01 | fail→pass | 26,772 | 6,781 | -75% | 1 | 1 | 0% | 5,965 | 5,900 | -1% | 0 | 0 | — |
case-02 | fail→fail | 31,682 | 4,217 | -87% | 1 | 1 | 0% | 6,204 | 5,900 | -5% | 0 | 0 | — |
case-03 | fail→fail | 36,542 | 5,250 | -86% | 1 | 1 | 0% | 6,149 | 6,226 | +1% | 0 | 0 | — |
case-04 | pass→pass | 12,450 | 8,001 | -36% | 1 | 1 | 0% | 2,325 | 7,109 | +206% | 0 | 0 | — |
case-05 | pass→pass | 16,573 | 14,189 | -14% | 1 | 1 | 0% | 2,749 | 7,848 | +185% | 0 | 0 | — |
case-08 | pass→pass | 13,440 | 8,866 | -34% | 1 | 1 | 0% | 2,070 | 7,165 | +246% | 0 | 0 | — |
case-09 | pass→pass | 13,982 | 9,608 | -31% | 1 | 1 | 0% | 2,180 | 7,233 | +232% | 0 | 0 | — |
case-10 | pass→fail | 13,465 | 10,837 | -20% | 1 | 1 | 0% | 2,178 | 7,279 | +234% | 0 | 0 | — |
case-11 | pass→pass | 10,860 | 6,832 | -37% | 1 | 1 | 0% | 2,171 | 6,768 | +212% | 0 | 0 | — |
case-12 | pass→pass | 13,981 | 9,051 | -35% | 1 | 1 | 0% | 2,628 | 7,281 | +177% | 0 | 0 | — |
case-13 | pass→pass | 11,800 | 9,736 | -17% | 1 | 1 | 0% | 2,053 | 7,350 | +258% | 0 | 0 | — |
case-14 | pass→pass | 11,634 | 9,108 | -22% | 1 | 1 | 0% | 1,905 | 7,116 | +274% | 0 | 0 | — |
case-15 | pass→pass | 13,012 | 8,117 | -38% | 1 | 1 | 0% | 2,469 | 7,179 | +191% | 0 | 0 | — |
case-16 | pass→pass | 11,968 | 7,983 | -33% | 1 | 1 | 0% | 2,013 | 6,849 | +240% | 0 | 0 | — |
case-22 | pass→pass | 11,247 | 14,420 | +28% | 1 | 1 | 0% | 2,352 | 8,539 | +263% | 0 | 0 | — |
case-17 | pass→pass | 11,202 | 6,606 | -41% | 1 | 1 | 0% | 2,214 | 6,845 | +209% | 0 | 0 | — |
case-18 | fail→pass | 5,774 | 7,638 | +32% | 1 | 1 | 0% | 956 | 6,755 | +607% | 0 | 0 | — |
case-19 | pass→pass | 19,345 | 19,257 | -0% | 1 | 1 | 0% | 3,851 | 8,696 | +126% | 0 | 0 | — |
case-20 | pass→pass | 16,281 | 13,514 | -17% | 1 | 1 | 0% | 3,197 | 8,208 | +157% | 0 | 0 | — |
case-21 | pass→pass | 15,450 | 11,064 | -28% | 1 | 1 | 0% | 3,094 | 7,836 | +153% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +5 percentage points is the difference between those two pass rates over the 22 comparable cases. 1 case got worse with the skill loaded, and it is included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.