| name | playwright-testing |
| description | E2E testing with Playwright - Page Objects, cross-browser, CI/CD |
| disable-model-invocation | false |
Playwright E2E Testing Skill
Load with: base.md + [framework].md
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
npm init playwright@latest
npm install -D @playwright/test
npx playwright install
Configuration
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: [
{ 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 12'] },
dependencies: ['setup'],
},
],
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:
page.getByRole('button', { name: 'Submit' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('link', { name: 'Sign up' })
page.getByRole('heading', { name: 'Welcome' })
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')
page.getByText('Welcome back')
page.getByTitle('Profile settings')
page.getByTestId('submit-button')
page.getByTestId('user-avatar')
page.locator('.btn-primary')
page.locator('#submit')
page.locator('//div[@class="container"]/button[1]')
Chaining Locators
const form = page.getByRole('form', { name: 'Login' });
await form.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
await form.getByRole('button', { name: 'Submit' }).click();
const productCard = page.getByTestId('product-card')
.filter({ hasText: 'Pro Plan' });
await productCard.getByRole('button', { name: 'Buy' }).click();
Page Object Model
Base Page
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');
}
get header() {
return this.page.getByRole('banner');
}
get footer() {
return this.page.getByRole('contentinfo');
}
async clickNavLink(name: ) {
..(, { name }).();
}
}
Page Implementation
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() {
.();
}
() {
..(email);
..(password);
..();
}
() {
(.).(message);
}
() {
(.).();
}
}
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');
}
() {
..();
..();
}
() {
(.).(name);
}
}
Export All Pages
export { BasePage } from './base.page';
export { LoginPage } from './login.page';
export { DashboardPage } from './dashboard.page';
Authentication
Global Auth Setup
import { test as setup, expect } from '@playwright/test';
import path from 'path';
const authFile = path.join(__dirname, '../.auth/user.json');
setup('authenticate', async ({ page }) => {
await page.goto('/login');
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();
await expect(page).toHaveURL(/.*dashboard/);
await page.context().storageState({ path: authFile });
});
Using Auth in Tests
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: '.auth/user.json',
},
dependencies: ['setup'],
},
],
});
Tests Without Auth
import { test } from '@playwright/test';
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
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages';
test.describe('Authentication', () => {
test.beforeEach(async ({ page }) => {
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(, ({ page }) => {
loginPage = (page);
loginPage.();
loginPage..();
(page.()).();
(page.()).();
});
});
User Flow Tests
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
test('complete purchase flow', async ({ page }) => {
await page.goto('/products');
await page.getByTestId('product-card')
.filter({ hasText: 'Pro Plan' })
.getByRole('button', { name: 'Add to cart' })
.click();
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByText('Pro Plan')).toBeVisible();
await expect(page.getByTestId('cart-total')).toContainText('$29.99');
await page.getByRole('button', { name: 'Checkout' }).click();
stripeFrame = page.();
stripeFrame.().();
stripeFrame.().();
stripeFrame.().();
page.(, { : }).();
(page).();
(page.(, { : })).();
});
});
Assertions
Web-First Assertions (Auto-Wait)
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/);
await page.waitForTimeout(3000);
Soft Assertions
await expect.soft(page.getByTestId('price')).toHaveText('$29.99');
await expect.soft(page.getByTestId('stock')).toHaveText('In Stock');
Common Assertions
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeAttached();
await expect(locator).toHaveText('exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveValue('input value');
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeFocused();
await expect(locator).toHaveCount(5);
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('Dashboard | App');
(page).();
Mocking & Network
Mock API Responses
test('shows error when API fails', async ({ page }) => {
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 }) => {
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, : , : },
]),
});
});
page.();
(page.()).();
(page.()).();
});
Wait for API Calls
test('submits form and shows success', async ({ page }) => {
await page.goto('/contact');
await page.getByLabel('Name').fill('John');
await page.getByLabel('Email').fill('john@example.com');
await page.getByLabel('Message').fill('Hello!');
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
await expect(page).toHaveScreenshot('homepage.png');
await expect(page.getByTestId('chart')).toHaveScreenshot('chart.png');
await expect(page).toHaveScreenshot('dashboard.png', {
maxDiffPixels: 100,
mask: [page.getByTestId('timestamp')],
});
CI/CD Integration
GitHub Actions
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:
Run Specific Tests
npx playwright test
npx playwright test e2e/tests/auth.spec.ts
npx playwright test --grep @critical
npx playwright test --headed
npx playwright test --project=chromium
npx playwright test --debug
npx playwright show-report
Test Data
Factories
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
BASE_URL=http://localhost:3000
TEST_USER_EMAIL=test@example.com
TEST_USER_PASSWORD=testpassword123
Debugging
Trace Viewer
use: {
trace: 'on-first-retry',
}
npx playwright show-trace trace.zip
Debug Mode
npx playwright test --debug
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
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);
const links = await page.locator('a[href]').all();
const hrefs = await Promise.all(
links.map(link => link.getAttribute('href'))
);
const uniqueLinks = [...new Set(hrefs.filter(Boolean))] as string[];
for (const href uniqueLinks) {
(href.() || href.() || href.()) {
;
}
url = href.() ? href : (href, page.()).;
response = request.(url, {
: ,
: ,
});
(
response.(),
).();
}
});
}
});
Comprehensive Link Crawler
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 = page.().();
( link links) {
href = link.();
(!href || href.() || href.() || href.()) {
;
}
fullUrl = href.() ? href : (href, currentUrl).;
response = request.(fullUrl, {
: ,
: ,
});
results.({
: fullUrl,
: response.(),
: currentUrl,
});
(fullUrl.(baseUrl) && !visited.(fullUrl)) {
toVisit.(fullUrl);
}
}
} (error) {
results.({
: currentUrl,
: ,
: ,
});
}
}
results;
}
(, ({ page, request, baseURL }) => {
results = (page, request, baseURL!);
deadLinks = results.( r. >= || r. === );
(deadLinks. > ) {
.();
deadLinks.( {
.();
});
}
(deadLinks, ).();
});
Image Link Validation
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;
if (url.startsWith('data:')) continue;
const response = await request.get(url);
expect(
response.ok(),
`Broken image: ${src}`
).toBeTruthy();
const contentType = response.headers()['content-type'];
expect(
contentType?.(),
).();
}
});
CI Integration for Link Checking
name: Link Check
on:
schedule:
- cron: '0 6 * * 1'
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
npm init playwright@latest
npx playwright test
npx playwright test --headed
npx playwright test --project=chromium
npx playwright test --grep @smoke
npx playwright test --debug
npx playwright show-report
npx playwright show-trace trace.zip
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"
}
}