playwright-testing

Cross-browser E2E testing with Page Objects, authentication, and CI/CD integration. Supports three browser engines (Chromium, Firefox, WebKit) plus mobile viewports with parallel test execution and automatic retries Page Object Model pattern with base classes, role-based locators, and web-first assertions that auto-wait and retry Built-in authentication setup, API mocking, visual testing, and comprehensive dead link detection across entire sites GitHub Actions integration with artifact uploads, environment secrets, and scheduled link-checking workflows

INSTALLATION
npx skills add https://github.com/alinaqi/claude-bootstrap --skill playwright-testing
Run in your project or agent environment. Adjust flags if your CLI version differs.

SKILL.md

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) &#x26;&#x26; !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"

}

}

BrowserAct

Let your agent run on any real-world website

Bypass CAPTCHA & anti-bot for free. Start local, scale to cloud.

Explore BrowserAct Skills →

Stop writing automation&scrapers

Install the CLI. Run your first Skill in 30 seconds. Scale when you're ready.

Start free
free · no credit card