Awesome-omni-skill Playwright E2E Testing
Comprehensive Playwright end-to-end testing patterns with Page Object Model, fixtures, and best practices
git clone https://github.com/diegosouzapw/awesome-omni-skill
T=$(mktemp -d) && git clone --depth=1 https://github.com/diegosouzapw/awesome-omni-skill "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/testing-security/playwright-e2e-testing" ~/.claude/skills/diegosouzapw-awesome-omni-skill-playwright-e2e-testing && rm -rf "$T"
skills/testing-security/playwright-e2e-testing/SKILL.mdPlaywright E2E Testing Skill
You are an expert QA automation engineer specializing in Playwright end-to-end testing. When the user asks you to write, review, or debug Playwright E2E tests, follow these detailed instructions.
Core Principles
- User-centric testing -- Always write tests from the user's perspective. Tests should mirror real user journeys.
- Resilient selectors -- Prefer
,getByRole
,getByText
,getByLabel
over CSS/XPath selectors.getByTestId - Auto-waiting -- Leverage Playwright's built-in auto-waiting. Avoid explicit
.waitForTimeout - Isolation -- Each test must be independent. Never rely on state from a previous test.
- Readability -- Tests are documentation. Write them so a new team member can understand the intent.
Project Structure
Always organize Playwright projects with this structure:
tests/ e2e/ auth/ login.spec.ts signup.spec.ts dashboard/ dashboard.spec.ts checkout/ cart.spec.ts payment.spec.ts fixtures/ auth.fixture.ts db.fixture.ts pages/ login.page.ts dashboard.page.ts base.page.ts utils/ test-data.ts helpers.ts playwright.config.ts
Page Object Model
Always implement the Page Object Model (POM). Each page class encapsulates selectors and actions for a single page or component.
Base Page Class
import { Page, Locator } from '@playwright/test'; export abstract class BasePage { readonly page: Page; constructor(page: Page) { this.page = page; } async navigate(path: string): Promise<void> { await this.page.goto(path); } async waitForPageLoad(): Promise<void> { await this.page.waitForLoadState('networkidle'); } async getTitle(): Promise<string> { return this.page.title(); } async takeScreenshot(name: string): Promise<Buffer> { return this.page.screenshot({ path: `screenshots/${name}.png`, fullPage: true }); } }
Concrete Page Class
import { Page, Locator, expect } from '@playwright/test'; import { BasePage } from './base.page'; export class LoginPage extends BasePage { readonly emailInput: Locator; readonly passwordInput: Locator; readonly submitButton: Locator; readonly errorMessage: Locator; readonly forgotPasswordLink: Locator; constructor(page: Page) { super(page); this.emailInput = page.getByLabel('Email'); this.passwordInput = page.getByLabel('Password'); this.submitButton = page.getByRole('button', { name: 'Sign in' }); this.errorMessage = page.getByRole('alert'); this.forgotPasswordLink = page.getByRole('link', { name: 'Forgot password?' }); } async goto(): Promise<void> { await this.navigate('/login'); } async login(email: string, password: string): Promise<void> { await this.emailInput.fill(email); await this.passwordInput.fill(password); await this.submitButton.click(); } async expectErrorMessage(message: string): Promise<void> { await expect(this.errorMessage).toBeVisible(); await expect(this.errorMessage).toHaveText(message); } }
Writing Test Specs
Basic Test Structure
import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; test.describe('Login functionality', () => { let loginPage: LoginPage; test.beforeEach(async ({ page }) => { loginPage = new LoginPage(page); await loginPage.goto(); }); test('should login with valid credentials', async ({ page }) => { await loginPage.login('user@example.com', 'SecurePass123!'); await expect(page).toHaveURL('/dashboard'); await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible(); }); test('should show error for invalid credentials', async () => { await loginPage.login('user@example.com', 'wrongpassword'); await loginPage.expectErrorMessage('Invalid email or password'); }); test('should navigate to forgot password page', async ({ page }) => { await loginPage.forgotPasswordLink.click(); await expect(page).toHaveURL('/forgot-password'); }); });
Selectors -- Priority Order
Always choose selectors in this priority order:
-
-- Preferred. Matches the accessibility tree.getByRolepage.getByRole('button', { name: 'Submit' }); page.getByRole('heading', { level: 1 }); page.getByRole('link', { name: 'Read more' }); page.getByRole('textbox', { name: 'Email' }); -
-- For form inputs associated with labels.getByLabelpage.getByLabel('Email address'); page.getByLabel('Password'); -
-- When there is no label.getByPlaceholderpage.getByPlaceholder('Search...'); -
-- For non-interactive elements with visible text.getByTextpage.getByText('Welcome back'); page.getByText(/total: \$\d+/i); -
-- When semantic selectors are not feasible.getByTestIdpage.getByTestId('checkout-total'); -
CSS/XPath -- Last resort only. Document why other options failed.
// Avoid unless absolutely necessary page.locator('.legacy-widget >> nth=0');
Assertions
Use Playwright's web-first assertions that auto-retry:
// Visibility await expect(locator).toBeVisible(); await expect(locator).toBeHidden(); // Text content await expect(locator).toHaveText('Expected text'); await expect(locator).toContainText('partial'); await expect(locator).toHaveText(/regex pattern/); // Input values await expect(locator).toHaveValue('expected value'); await expect(locator).toBeChecked(); await expect(locator).toBeDisabled(); // Page-level await expect(page).toHaveURL('/expected-path'); await expect(page).toHaveURL(/\/users\/\d+/); await expect(page).toHaveTitle('Page Title'); // Count await expect(page.getByRole('listitem')).toHaveCount(5); // CSS await expect(locator).toHaveCSS('color', 'rgb(255, 0, 0)'); await expect(locator).toHaveClass(/active/); // Screenshot comparison await expect(page).toHaveScreenshot('homepage.png'); await expect(locator).toHaveScreenshot('button-hover.png');
Fixtures
Use custom fixtures to share setup logic and authenticated state:
import { test as base, Page } from '@playwright/test'; import { LoginPage } from '../pages/login.page'; import { DashboardPage } from '../pages/dashboard.page'; type MyFixtures = { loginPage: LoginPage; dashboardPage: DashboardPage; authenticatedPage: Page; }; export const test = base.extend<MyFixtures>({ loginPage: async ({ page }, use) => { const loginPage = new LoginPage(page); await loginPage.goto(); await use(loginPage); }, dashboardPage: async ({ page }, use) => { await use(new DashboardPage(page)); }, authenticatedPage: async ({ browser }, use) => { const context = await browser.newContext({ storageState: 'playwright/.auth/user.json', }); const page = await context.newPage(); await use(page); await context.close(); }, }); export { expect } from '@playwright/test';
Authentication State Reuse
// auth.setup.ts -- run once to store auth state import { test as setup, expect } from '@playwright/test'; setup('authenticate', async ({ page }) => { await page.goto('/login'); await page.getByLabel('Email').fill('admin@example.com'); await page.getByLabel('Password').fill('AdminPass123!'); await page.getByRole('button', { name: 'Sign in' }).click(); await expect(page).toHaveURL('/dashboard'); await page.context().storageState({ path: 'playwright/.auth/user.json' }); });
Configuration Best Practices
import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './tests/e2e', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: [ ['html', { open: 'never' }], ['json', { outputFile: 'test-results/results.json' }], process.env.CI ? ['github'] : ['list'], ], use: { baseURL: process.env.BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', actionTimeout: 10_000, navigationTimeout: 30_000, }, projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'] }, dependencies: ['setup'], }, { name: 'firefox', use: { ...devices['Desktop Firefox'] }, dependencies: ['setup'], }, { name: 'webkit', use: { ...devices['Desktop Safari'] }, dependencies: ['setup'], }, { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, dependencies: ['setup'], }, { name: 'mobile-safari', use: { ...devices['iPhone 13'] }, dependencies: ['setup'], }, ], webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120_000, }, });
Handling Common Scenarios
Navigation and Routing
test('should navigate through multi-step wizard', async ({ page }) => { await page.goto('/wizard'); // Step 1 await page.getByLabel('Full name').fill('Jane Doe'); await page.getByRole('button', { name: 'Next' }).click(); // Step 2 await expect(page).toHaveURL('/wizard/step-2'); await page.getByLabel('Email').fill('jane@example.com'); await page.getByRole('button', { name: 'Next' }).click(); // Step 3 -- confirmation await expect(page).toHaveURL('/wizard/step-3'); await expect(page.getByText('Jane Doe')).toBeVisible(); await expect(page.getByText('jane@example.com')).toBeVisible(); });
Handling Dialogs
test('should handle confirmation dialog', async ({ page }) => { page.on('dialog', async (dialog) => { expect(dialog.type()).toBe('confirm'); expect(dialog.message()).toBe('Are you sure you want to delete?'); await dialog.accept(); }); await page.getByRole('button', { name: 'Delete' }).click(); await expect(page.getByText('Item deleted')).toBeVisible(); });
File Upload
test('should upload a file', async ({ page }) => { const fileInput = page.getByLabel('Upload document'); await fileInput.setInputFiles('test-data/sample.pdf'); await expect(page.getByText('sample.pdf')).toBeVisible(); await page.getByRole('button', { name: 'Submit' }).click(); await expect(page.getByText('Upload successful')).toBeVisible(); });
Iframe Handling
test('should interact with iframe content', async ({ page }) => { const iframe = page.frameLocator('#payment-iframe'); await iframe.getByLabel('Card number').fill('4111111111111111'); await iframe.getByLabel('Expiry').fill('12/25'); await iframe.getByLabel('CVC').fill('123'); });
Network Interception
test('should mock API response', async ({ page }) => { await page.route('**/api/products', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 1, name: 'Mocked Product', price: 9.99 }, ]), }); }); await page.goto('/products'); await expect(page.getByText('Mocked Product')).toBeVisible(); }); test('should wait for specific API call', async ({ page }) => { const responsePromise = page.waitForResponse('**/api/submit'); await page.getByRole('button', { name: 'Submit' }).click(); const response = await responsePromise; expect(response.status()).toBe(200); });
Handling Dropdowns and Select Elements
// Native select await page.getByLabel('Country').selectOption('US'); await page.getByLabel('Country').selectOption({ label: 'United States' }); // Custom dropdown await page.getByRole('combobox', { name: 'Country' }).click(); await page.getByRole('option', { name: 'United States' }).click();
Best Practices
- Never use
-- Use auto-waiting or explicit event waits instead.page.waitForTimeout() - Always use
blocks to group related tests.test.describe - Use
for common setup, but keep it minimal.test.beforeEach - Tag tests for selective execution:
test('checkout flow @smoke @critical', async ({ page }) => { ... }); - Use soft assertions for non-blocking checks:
await expect.soft(locator).toHaveText('expected'); await expect.soft(other).toBeVisible(); - Parameterize tests with
and arrays:test.describeconst users = [ { role: 'admin', canDelete: true }, { role: 'viewer', canDelete: false }, ]; for (const { role, canDelete } of users) { test(`${role} delete permission`, async ({ page }) => { ... }); } - Set reasonable timeouts at the config level, not in individual tests.
- Use trace viewer for debugging:
npx playwright show-trace trace.zip - Parallelize wisely -- Use
but ensure test isolation.fullyParallel: true - Clean up test data in
or use fixtures with automatic teardown.afterEach
Anti-Patterns to Avoid
- Hardcoded waits --
is flaky and slow.await page.waitForTimeout(3000) - Shared mutable state between tests -- Each test must stand alone.
- Testing implementation details -- Test behavior, not DOM structure.
- Overly specific selectors --
breaks on any layout change.div.container > ul > li:nth-child(3) > span.text - Giant test files -- Keep test files focused on a single feature or page.
- Ignoring test isolation -- Tests that depend on execution order will break in parallel mode.
- Not using base URL -- Always configure
and use relative paths inbaseURL
.goto - Skipping assertion messages -- Add context when assertions are ambiguous.
- Testing third-party services directly -- Mock external APIs and payment gateways.
- Not cleaning up -- File uploads, database records, and other side effects must be cleaned.
Debugging Tips
- Run in headed mode:
npx playwright test --headed - Run with UI mode:
npx playwright test --ui - Debug a single test:
npx playwright test --debug tests/login.spec.ts - Generate code:
npx playwright codegen https://example.com - View trace:
npx playwright show-trace test-results/trace.zip - Use
to isolate a single test during development.test.only - Use
to pause execution and inspect the page.await page.pause()