Claude-bootstrap playwright-testing
E2E testing with Playwright - Page Objects, cross-browser, CI/CD
install
source · Clone the upstream repo
git clone https://github.com/alinaqi/claude-bootstrap
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/alinaqi/claude-bootstrap "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/playwright-testing" ~/.claude/skills/alinaqi-claude-bootstrap-playwright-testing && rm -rf "$T"
manifest:
skills/playwright-testing/SKILL.mdsource content
Playwright E2E Testing Skill
For end-to-end testing of web applications with Playwright - cross-browser, fast, reliable.
Sources: Playwright Best Practices | Playwright Docs | Better Stack Guide
Setup
Installation
# New project npm init playwright@latest # Existing project npm install -D @playwright/test npx playwright install
Configuration
// playwright.config.ts 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'], process.env.CI ? ['github'] : ['line'], ], use: { baseURL: process.env.BASE_URL || 'http://localhost:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', }, projects: [ // Auth setup - runs once before all tests { 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'], }, // Mobile viewports { name: 'mobile-chrome', use: { ...devices['Pixel 5'] }, dependencies: ['setup'], }, { name: 'mobile-safari', use: { ...devices['iPhone 12'] }, dependencies: ['setup'], }, ], // Start dev server before tests webServer: { command: 'npm run dev', url: 'http://localhost:3000', reuseExistingServer: !process.env.CI, timeout: 120 * 1000, }, });
Project Structure
project/ ├── e2e/ │ ├── fixtures/ │ │ ├── auth.fixture.ts # Auth fixtures │ │ └── test.fixture.ts # Extended test with fixtures │ ├── pages/ │ │ ├── base.page.ts # Base page object │ │ ├── login.page.ts # Login page object │ │ ├── dashboard.page.ts # Dashboard page object │ │ └── index.ts # Export all pages │ ├── tests/ │ │ ├── auth.spec.ts # Auth tests │ │ ├── dashboard.spec.ts # Dashboard tests │ │ └── checkout.spec.ts # Checkout flow tests │ ├── utils/ │ │ ├── helpers.ts # Test helpers │ │ └── test-data.ts # Test data factories │ └── auth.setup.ts # Global auth setup ├── playwright.config.ts └── .auth/ # Stored auth state (gitignored)
Locator Strategy (Priority Order)
Use locators that mirror how users interact with the page:
// ✅ BEST: Role-based (accessible, resilient) page.getByRole('button', { name: 'Submit' }) page.getByRole('textbox', { name: 'Email' }) page.getByRole('link', { name: 'Sign up' }) page.getByRole('heading', { name: 'Welcome' }) // ✅ GOOD: User-facing text page.getByLabel('Email address') page.getByPlaceholder('Enter your email') page.getByText('Welcome back') page.getByTitle('Profile settings') // ✅ GOOD: Test IDs (stable, explicit) page.getByTestId('submit-button') page.getByTestId('user-avatar') // ⚠️ AVOID: CSS selectors (brittle) page.locator('.btn-primary') page.locator('#submit') // ❌ NEVER: XPath (extremely brittle) page.locator('//div[@class="container"]/button[1]')
Chaining Locators
// Narrow down to specific section const form = page.getByRole('form', { name: 'Login' }); await form.getByRole('textbox', { name: 'Email' }).fill('user@example.com'); await form.getByRole('button', { name: 'Submit' }).click(); // Filter within a list const productCard = page.getByTestId('product-card') .filter({ hasText: 'Pro Plan' }); await productCard.getByRole('button', { name: 'Buy' }).click();
Page Object Model
Base Page
// e2e/pages/base.page.ts import { Page, Locator } from '@playwright/test'; export abstract class BasePage { constructor(protected page: Page) {} async navigate(path: string = '/') { await this.page.goto(path); } async waitForPageLoad() { await this.page.waitForLoadState('networkidle'); } // Common elements get header() { return this.page.getByRole('banner'); } get footer() { return this.page.getByRole('contentinfo'); } // Common actions async clickNavLink(name: string) { await this.header.getByRole('link', { name }).click(); } }
Page Implementation
// e2e/pages/login.page.ts import { Page, 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; 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'); } async goto() { await this.navigate('/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); } async expectLoggedIn() { await expect(this.page).toHaveURL(/.*dashboard/); } }
// e2e/pages/dashboard.page.ts import { Page, Locator, expect } from '@playwright/test'; import { BasePage } from './base.page'; export class DashboardPage extends BasePage { readonly welcomeHeading: Locator; readonly userMenu: Locator; readonly logoutButton: Locator; constructor(page: Page) { super(page); this.welcomeHeading = page.getByRole('heading', { name: /welcome/i }); this.userMenu = page.getByTestId('user-menu'); this.logoutButton = page.getByRole('button', { name: 'Logout' }); } async goto() { await this.navigate('/dashboard'); } async logout() { await this.userMenu.click(); await this.logoutButton.click(); } async expectWelcome(name: string) { await expect(this.welcomeHeading).toContainText(name); } }
Export All Pages
// e2e/pages/index.ts export { BasePage } from './base.page'; export { LoginPage } from './login.page'; export { DashboardPage } from './dashboard.page';
Authentication
Global Auth Setup
// e2e/auth.setup.ts import { test as setup, expect } from '@playwright/test'; import path from 'path'; const authFile = path.join(__dirname, '../.auth/user.json'); setup('authenticate', async ({ page }) => { // Go to login page await page.goto('/login'); // Login with test credentials await page.getByLabel('Email').fill(process.env.TEST_USER_EMAIL!); await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!); await page.getByRole('button', { name: 'Sign in' }).click(); // Wait for auth to complete await expect(page).toHaveURL(/.*dashboard/); // Save auth state for reuse await page.context().storageState({ path: authFile }); });
Using Auth in Tests
// playwright.config.ts export default defineConfig({ projects: [ { name: 'setup', testMatch: /.*\.setup\.ts/ }, { name: 'chromium', use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json', }, dependencies: ['setup'], }, ], });
Tests Without Auth
// e2e/tests/public.spec.ts import { test } from '@playwright/test'; // Override to skip auth test.use({ storageState: { cookies: [], origins: [] } }); test('homepage loads for anonymous users', async ({ page }) => { await page.goto('/'); await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible(); });
Writing Tests
Basic Test Structure
// e2e/tests/auth.spec.ts import { test, expect } from '@playwright/test'; import { LoginPage } from '../pages'; test.describe('Authentication', () => { test.beforeEach(async ({ page }) => { // Skip stored auth for login tests await page.context().clearCookies(); }); test('successful login redirects to dashboard', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('user@example.com', 'password123'); await loginPage.expectLoggedIn(); }); test('invalid credentials show error', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.login('wrong@example.com', 'wrongpass'); await loginPage.expectError('Invalid email or password'); }); test('empty form shows validation errors', async ({ page }) => { const loginPage = new LoginPage(page); await loginPage.goto(); await loginPage.submitButton.click(); await expect(page.getByText('Email is required')).toBeVisible(); await expect(page.getByText('Password is required')).toBeVisible(); }); });
User Flow Tests
// e2e/tests/checkout.spec.ts import { test, expect } from '@playwright/test'; test.describe('Checkout Flow', () => { test('complete purchase flow', async ({ page }) => { // 1. Browse products await page.goto('/products'); await page.getByTestId('product-card') .filter({ hasText: 'Pro Plan' }) .getByRole('button', { name: 'Add to cart' }) .click(); // 2. View cart await page.getByRole('link', { name: 'Cart' }).click(); await expect(page.getByText('Pro Plan')).toBeVisible(); await expect(page.getByTestId('cart-total')).toContainText('$29.99'); // 3. Checkout await page.getByRole('button', { name: 'Checkout' }).click(); // 4. Fill payment (use Stripe test card) const stripeFrame = page.frameLocator('iframe[name*="stripe"]'); await stripeFrame.getByPlaceholder('Card number').fill('4242424242424242'); await stripeFrame.getByPlaceholder('MM / YY').fill('12/30'); await stripeFrame.getByPlaceholder('CVC').fill('123'); // 5. Complete purchase await page.getByRole('button', { name: 'Pay now' }).click(); // 6. Verify success await expect(page).toHaveURL(/.*success/); await expect(page.getByRole('heading', { name: 'Thank you' })).toBeVisible(); }); });
Assertions
Web-First Assertions (Auto-Wait)
// ✅ These wait and retry automatically await expect(page.getByRole('button')).toBeVisible(); await expect(page.getByRole('button')).toBeEnabled(); await expect(page.getByRole('button')).toHaveText('Submit'); await expect(page).toHaveURL('/dashboard'); await expect(page).toHaveTitle(/Dashboard/); // ❌ Avoid manual waits await page.waitForTimeout(3000); // NEVER do this
Soft Assertions
// Continue test even if assertion fails await expect.soft(page.getByTestId('price')).toHaveText('$29.99'); await expect.soft(page.getByTestId('stock')).toHaveText('In Stock'); // Fail at end if any soft assertions failed
Common Assertions
// Visibility await expect(locator).toBeVisible(); await expect(locator).toBeHidden(); await expect(locator).toBeAttached(); // Text content await expect(locator).toHaveText('exact text'); await expect(locator).toContainText('partial'); await expect(locator).toHaveValue('input value'); // State await expect(locator).toBeEnabled(); await expect(locator).toBeDisabled(); await expect(locator).toBeChecked(); await expect(locator).toBeFocused(); // Count await expect(locator).toHaveCount(5); // Page await expect(page).toHaveURL('/dashboard'); await expect(page).toHaveTitle('Dashboard | App'); await expect(page).toHaveScreenshot('dashboard.png');
Mocking & Network
Mock API Responses
test('shows error when API fails', async ({ page }) => { // Mock API to return error await page.route('**/api/users', (route) => { route.fulfill({ status: 500, body: JSON.stringify({ error: 'Server error' }), }); }); await page.goto('/users'); await expect(page.getByText('Failed to load users')).toBeVisible(); }); test('displays user data from API', async ({ page }) => { // Mock successful response await page.route('**/api/users', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([ { id: 1, name: 'John Doe', email: 'john@example.com' }, { id: 2, name: 'Jane Doe', email: 'jane@example.com' }, ]), }); }); await page.goto('/users'); await expect(page.getByText('John Doe')).toBeVisible(); await expect(page.getByText('Jane Doe')).toBeVisible(); });
Wait for API Calls
test('submits form and shows success', async ({ page }) => { await page.goto('/contact'); // Fill form await page.getByLabel('Name').fill('John'); await page.getByLabel('Email').fill('john@example.com'); await page.getByLabel('Message').fill('Hello!'); // Wait for API call on submit const responsePromise = page.waitForResponse('**/api/contact'); await page.getByRole('button', { name: 'Send' }).click(); const response = await responsePromise; expect(response.status()).toBe(200); await expect(page.getByText('Message sent!')).toBeVisible(); });
Visual Testing
// Full page screenshot await expect(page).toHaveScreenshot('homepage.png'); // Element screenshot await expect(page.getByTestId('chart')).toHaveScreenshot('chart.png'); // With options await expect(page).toHaveScreenshot('dashboard.png', { maxDiffPixels: 100, mask: [page.getByTestId('timestamp')], // Ignore dynamic content });
CI/CD Integration
GitHub Actions
# .github/workflows/e2e.yml name: E2E Tests on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: 'npm' - name: Install dependencies run: npm ci - name: Install Playwright browsers run: npx playwright install --with-deps chromium - name: Run E2E tests run: npx playwright test --project=chromium env: BASE_URL: ${{ secrets.STAGING_URL }} TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }} TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }} - uses: actions/upload-artifact@v4 if: failure() with: name: playwright-report path: playwright-report/ retention-days: 7
Run Specific Tests
# Run all tests npx playwright test # Run specific file npx playwright test e2e/tests/auth.spec.ts # Run tests with tag npx playwright test --grep @critical # Run in headed mode (debug) npx playwright test --headed # Run specific browser npx playwright test --project=chromium # Debug mode npx playwright test --debug # Show HTML report npx playwright show-report
Test Data
Factories
// e2e/utils/test-data.ts import { faker } from '@faker-js/faker'; export const createUser = (overrides = {}) => ({ email: faker.internet.email(), password: faker.internet.password({ length: 12 }), name: faker.person.fullName(), ...overrides, }); export const createProduct = (overrides = {}) => ({ name: faker.commerce.productName(), price: faker.commerce.price({ min: 10, max: 100 }), description: faker.commerce.productDescription(), ...overrides, });
Environment Variables
# .env.test BASE_URL=http://localhost:3000 TEST_USER_EMAIL=test@example.com TEST_USER_PASSWORD=testpassword123
Debugging
Trace Viewer
// Enable in config for failures use: { trace: 'on-first-retry', } // View traces npx playwright show-trace trace.zip
Debug Mode
# Step through test npx playwright test --debug # Pause at specific point await page.pause(); // In test code
VS Code Extension
Install "Playwright Test for VS Code" for:
- Run tests from editor
- Debug with breakpoints
- Pick locators visually
- Watch mode
Dead Link Detection (REQUIRED)
Every project MUST include dead link detection tests. Run these on every deployment.
Link Validator Test
// e2e/tests/links.spec.ts import { test, expect } from '@playwright/test'; const PAGES_TO_CHECK = ['/', '/about', '/pricing', '/blog', '/contact']; test.describe('Dead Link Detection', () => { for (const pagePath of PAGES_TO_CHECK) { test(`no dead links on ${pagePath}`, async ({ page, request }) => { await page.goto(pagePath); // Get all links on the page const links = await page.locator('a[href]').all(); const hrefs = await Promise.all( links.map(link => link.getAttribute('href')) ); // Filter to internal and absolute external links const uniqueLinks = [...new Set(hrefs.filter(Boolean))] as string[]; for (const href of uniqueLinks) { // Skip mailto, tel, and anchor links if (href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('#')) { continue; } // Build full URL const url = href.startsWith('http') ? href : new URL(href, page.url()).href; // Check link status const response = await request.get(url, { timeout: 10000, ignoreHTTPSErrors: true, }); expect( response.ok(), `Dead link found on ${pagePath}: ${href} returned ${response.status()}` ).toBeTruthy(); } }); } });
Comprehensive Link Crawler
// e2e/tests/site-links.spec.ts import { test, expect, Page, APIRequestContext } from '@playwright/test'; interface LinkResult { url: string; status: number; foundOn: string; } async function checkAllLinks( page: Page, request: APIRequestContext, startUrl: string ): Promise<LinkResult[]> { const visited = new Set<string>(); const results: LinkResult[] = []; const toVisit = [startUrl]; const baseUrl = new URL(startUrl).origin; while (toVisit.length > 0) { const currentUrl = toVisit.pop()!; if (visited.has(currentUrl)) continue; visited.add(currentUrl); try { await page.goto(currentUrl); const links = await page.locator('a[href]').all(); for (const link of links) { const href = await link.getAttribute('href'); if (!href || href.startsWith('#') || href.startsWith('mailto:') || href.startsWith('tel:')) { continue; } const fullUrl = href.startsWith('http') ? href : new URL(href, currentUrl).href; // Check link const response = await request.get(fullUrl, { timeout: 10000, ignoreHTTPSErrors: true, }); results.push({ url: fullUrl, status: response.status(), foundOn: currentUrl, }); // Add internal links to queue if (fullUrl.startsWith(baseUrl) && !visited.has(fullUrl)) { toVisit.push(fullUrl); } } } catch (error) { results.push({ url: currentUrl, status: 0, foundOn: 'navigation', }); } } return results; } test('no dead links on entire site', async ({ page, request, baseURL }) => { const results = await checkAllLinks(page, request, baseURL!); const deadLinks = results.filter(r => r.status >= 400 || r.status === 0); if (deadLinks.length > 0) { console.error('Dead links found:'); deadLinks.forEach(link => { console.error(` ${link.url} (${link.status}) - found on ${link.foundOn}`); }); } expect(deadLinks, `Found ${deadLinks.length} dead links`).toHaveLength(0); });
Image Link Validation
// e2e/tests/images.spec.ts import { test, expect } from '@playwright/test'; test('no broken images on homepage', async ({ page, request }) => { await page.goto('/'); const images = await page.locator('img[src]').all(); for (const img of images) { const src = await img.getAttribute('src'); if (!src) continue; const url = src.startsWith('http') ? src : new URL(src, page.url()).href; // Skip data URLs if (url.startsWith('data:')) continue; const response = await request.get(url); expect( response.ok(), `Broken image: ${src}` ).toBeTruthy(); // Verify it's actually an image const contentType = response.headers()['content-type']; expect( contentType?.startsWith('image/'), `${src} is not an image (${contentType})` ).toBeTruthy(); } });
CI Integration for Link Checking
# .github/workflows/link-check.yml name: Link Check on: schedule: - cron: '0 6 * * 1' # Weekly on Monday push: branches: [main] jobs: link-check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: npm ci - run: npx playwright install chromium - run: npx playwright test e2e/tests/links.spec.ts --project=chromium env: BASE_URL: ${{ secrets.PRODUCTION_URL }}
Anti-Patterns
- Hardcoded waits - Use auto-waiting assertions instead
- CSS/XPath selectors - Use role/text/testid locators
- Testing third-party sites - Mock external dependencies
- Shared state between tests - Each test must be isolated
- Missing awaits - Use ESLint rule
no-floating-promises - Flaky time-based tests - Mock dates/times
- Testing implementation details - Test user-visible behavior
- Huge test files - Split by feature/page
Quick Reference
# Install npm init playwright@latest # Run tests npx playwright test npx playwright test --headed npx playwright test --project=chromium npx playwright test --grep @smoke # Debug npx playwright test --debug npx playwright show-report npx playwright show-trace trace.zip # Generate tests npx playwright codegen localhost:3000
Package.json Scripts
{ "scripts": { "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed", "test:e2e:debug": "playwright test --debug", "test:e2e:report": "playwright show-report", "test:e2e:codegen": "playwright codegen" } }