webapp-testing
Web Application Testing with Playwright
Comprehensive E2E testing patterns for web applications.
Quick Start
Python Setup
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto('http://localhost:3000')
page.wait_for_load_state('networkidle')
# ... test logic
browser.close()
JavaScript/TypeScript Setup
import { test, expect } from '@playwright/test';
test('example test', async ({ page }) => {
await page.goto('http://localhost:3000');
await expect(page.locator('h1')).toContainText('Welcome');
});
Server Management
Using Helper Scripts
Single server:
python scripts/with_server.py --server "npm run dev" --port 5173 -- python your_test.py
Multiple servers:
python scripts/with_server.py \
--server "cd backend && python server.py" --port 3000 \
--server "cd frontend && npm run dev" --port 5173 \
-- python your_test.py
Playwright Config (playwright.config.ts)
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Selectors
Best Practices
// BEST: Test IDs (most reliable)
page.locator('[data-testid="submit-button"]')
page.getByTestId('submit-button')
// GOOD: Role-based (accessible)
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { level: 1 })
page.getByRole('link', { name: 'Learn more' })
// GOOD: Label-based (forms)
page.getByLabel('Email address')
page.getByPlaceholder('Enter your email')
// GOOD: Text content
page.getByText('Welcome back')
page.getByText(/welcome/i) // Case-insensitive regex
// AVOID: CSS selectors (brittle)
page.locator('.btn-primary') // Class might change
page.locator('#submit') // ID might change
Selector Chaining
// Find within a container
const form = page.locator('form[data-testid="login-form"]');
await form.getByLabel('Email').fill('user@example.com');
await form.getByRole('button', { name: 'Log in' }).click();
// Filter results
await page.getByRole('listitem')
.filter({ hasText: 'Product 1' })
.getByRole('button', { name: 'Add to cart' })
.click();
Common Test Patterns
Authentication Flow
import { test, expect } from '@playwright/test';
test.describe('Authentication', () => {
test('successful login', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Log in' }).click();
// Verify redirect to dashboard
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Welcome back')).toBeVisible();
});
test('invalid credentials show error', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('wrong@example.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Log in' }).click();
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL('/login'); // Still on login page
});
test('logout', async ({ page }) => {
// Login first (or use authenticated state)
await page.goto('/dashboard');
await page.getByRole('button', { name: 'Logout' }).click();
await expect(page).toHaveURL('/login');
});
});
Form Submission
test.describe('Contact Form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/contact');
});
test('submits form with valid data', async ({ page }) => {
await page.getByLabel('Name').fill('John Doe');
await page.getByLabel('Email').fill('john@example.com');
await page.getByLabel('Message').fill('This is a test message');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Message sent successfully')).toBeVisible();
});
test('shows validation errors for empty fields', async ({ page }) => {
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Name is required')).toBeVisible();
await expect(page.getByText('Email is required')).toBeVisible();
});
test('validates email format', async ({ page }) => {
await page.getByLabel('Name').fill('John');
await page.getByLabel('Email').fill('invalid-email');
await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByText('Invalid email address')).toBeVisible();
});
});
Navigation Testing
test.describe('Navigation', () => {
test('main menu links work', async ({ page }) => {
await page.goto('/');
// Test each navigation link
const navLinks = [
{ name: 'Home', url: '/' },
{ name: 'About', url: '/about' },
{ name: 'Products', url: '/products' },
{ name: 'Contact', url: '/contact' },
];
for (const link of navLinks) {
await page.getByRole('link', { name: link.name }).click();
await expect(page).toHaveURL(link.url);
}
});
test('breadcrumbs show correct path', async ({ page }) => {
await page.goto('/products/category/item-1');
const breadcrumbs = page.getByRole('navigation', { name: 'Breadcrumb' });
await expect(breadcrumbs.getByText('Home')).toBeVisible();
await expect(breadcrumbs.getByText('Products')).toBeVisible();
await expect(breadcrumbs.getByText('Category')).toBeVisible();
});
});
CRUD Operations
test.describe('Product Management', () => {
test('creates new product', async ({ page }) => {
await page.goto('/admin/products');
await page.getByRole('button', { name: 'Add Product' }).click();
await page.getByLabel('Name').fill('New Product');
await page.getByLabel('Price').fill('29.99');
await page.getByLabel('Description').fill('Product description');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Product created')).toBeVisible();
await expect(page.getByText('New Product')).toBeVisible();
});
test('edits existing product', async ({ page }) => {
await page.goto('/admin/products');
// Find product row and click edit
await page.getByRole('row', { name: /Existing Product/ })
.getByRole('button', { name: 'Edit' })
.click();
await page.getByLabel('Name').fill('Updated Product');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Product updated')).toBeVisible();
await expect(page.getByText('Updated Product')).toBeVisible();
});
test('deletes product with confirmation', async ({ page }) => {
await page.goto('/admin/products');
// Click delete button
await page.getByRole('row', { name: /Product to Delete/ })
.getByRole('button', { name: 'Delete' })
.click();
// Handle confirmation dialog
await page.getByRole('button', { name: 'Confirm' }).click();
await expect(page.getByText('Product deleted')).toBeVisible();
await expect(page.getByText('Product to Delete')).not.toBeVisible();
});
});
Modal/Dialog Testing
test.describe('Modal Dialogs', () => {
test('opens and closes modal', async ({ page }) => {
await page.goto('/');
// Open modal
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Close with X button
await page.getByRole('button', { name: 'Close' }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('closes modal on escape key', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});
test('closes modal on backdrop click', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: 'Open Modal' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// Click outside modal
await page.locator('.modal-backdrop').click({ position: { x: 10, y: 10 } });
await expect(page.getByRole('dialog')).not.toBeVisible();
});
});
Waiting Strategies
Explicit Waits
// Wait for element
await page.waitForSelector('[data-testid="content"]');
// Wait for element state
await page.getByRole('button').waitFor({ state: 'visible' });
await page.getByRole('button').waitFor({ state: 'hidden' });
// Wait for navigation
await page.waitForURL('/dashboard');
await page.waitForURL(/\/user\/\d+/);
// Wait for network
await page.waitForResponse('/api/users');
await page.waitForResponse(response =>
response.url().includes('/api/') && response.status() === 200
);
// Wait for load state
await page.waitForLoadState('networkidle');
await page.waitForLoadState('domcontentloaded');
Auto-Waiting
// Playwright auto-waits for these
await page.click('button'); // Waits for button to be actionable
await page.fill('input', 'text'); // Waits for input to be editable
await expect(locator).toBeVisible(); // Waits up to timeout
Assertions
Common Assertions
// Visibility
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).not.toBeVisible();
// Text content
await expect(locator).toHaveText('exact text');
await expect(locator).toContainText('partial');
await expect(locator).toHaveText(/regex/i);
// Attributes
await expect(locator).toHaveAttribute('href', '/about');
await expect(locator).toHaveClass(/active/);
await expect(locator).toHaveId('main-content');
// Input values
await expect(locator).toHaveValue('input value');
await expect(locator).toBeChecked();
await expect(locator).toBeDisabled();
await expect(locator).toBeEditable();
// Count
await expect(locator).toHaveCount(5);
// Page assertions
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('Dashboard | My App');
Soft Assertions
// Continue test even if assertion fails
await expect.soft(locator).toHaveText('text');
await expect.soft(locator).toBeVisible();
// Check all soft assertions at end
expect(test.info().errors).toHaveLength(0);
API Testing Integration
Mock API Responses
test('shows loading and data states', async ({ page }) => {
// Intercept API request
await page.route('/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'John' },
{ id: 2, name: 'Jane' },
]),
});
});
await page.goto('/users');
await expect(page.getByText('John')).toBeVisible();
await expect(page.getByText('Jane')).toBeVisible();
});
test('handles API errors gracefully', async ({ page }) => {
await page.route('/api/users', route =>
route.fulfill({ status: 500 })
);
await page.goto('/users');
await expect(page.getByText('Failed to load users')).toBeVisible();
});
Wait for API Calls
test('submits form and waits for API', async ({ page }) => {
await page.goto('/contact');
// Start waiting for API response before triggering it
const responsePromise = page.waitForResponse('/api/contact');
await page.getByLabel('Email').fill('test@example.com');
await page.getByRole('button', { name: 'Submit' }).click();
const response = await responsePromise;
expect(response.status()).toBe(200);
});
Visual Testing
Screenshots
test('homepage visual test', async ({ page }) => {
await page.goto('/');
// Full page screenshot
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
});
});
test('component visual test', async ({ page }) => {
await page.goto('/');
// Element screenshot
await expect(page.getByTestId('header')).toHaveScreenshot('header.png');
});
Screenshot Options
await page.screenshot({
path: 'screenshots/test.png',
fullPage: true,
animations: 'disabled', // Reduce flakiness
mask: [page.locator('.dynamic-content')], // Hide changing content
});
Authentication Reuse
Save Auth State
// auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password');
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
// Save signed-in state
await page.context().storageState({ path: authFile });
});
Use Auth State
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'tests',
dependencies: ['setup'],
use: {
storageState: 'playwright/.auth/user.json',
},
},
],
});
Accessibility Testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Accessibility', () => {
test('homepage has no a11y violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});
test('form is accessible', async ({ page }) => {
await page.goto('/contact');
const results = await new AxeBuilder({ page })
.include('form')
.analyze();
expect(results.violations).toEqual([]);
});
});
Performance Testing
test('page loads within performance budget', async ({ page }) => {
await page.goto('/');
const metrics = await page.evaluate(() =>
JSON.stringify(window.performance.timing)
);
const timing = JSON.parse(metrics);
const loadTime = timing.loadEventEnd - timing.navigationStart;
expect(loadTime).toBeLessThan(3000); // 3 seconds
});
test('tracks Core Web Vitals', async ({ page }) => {
await page.goto('/');
const lcp = await page.evaluate(() => {
return new Promise(resolve => {
new PerformanceObserver(list => {
const entries = list.getEntries();
resolve(entries[entries.length - 1].startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });
});
});
expect(lcp).toBeLessThan(2500); // Good LCP is < 2.5s
});
Debug Helpers
Debugging Commands
# Run with headed browser
npx playwright test --headed
# Run with debugging UI
npx playwright test --debug
# Run specific test
npx playwright test -g "test name"
# Show report
npx playwright show-report
In-Test Debugging
test('debug example', async ({ page }) => {
await page.goto('/');
// Pause execution
await page.pause();
// Take screenshot
await page.screenshot({ path: 'debug.png' });
// Log to console
console.log(await page.content());
// Slow down
await page.setDefaultTimeout(30000);
});
Console Logs
# Python: Capture browser console
page.on('console', lambda msg: print(f'Browser log: {msg.text}'))
page.on('pageerror', lambda err: print(f'Browser error: {err}'))
// TypeScript: Capture browser console
page.on('console', msg => console.log('Browser:', msg.text()));
page.on('pageerror', err => console.log('Error:', err));
Test Organization
File Structure
tests/
├── e2e/
│ ├── auth/
│ │ ├── login.spec.ts
│ │ └── logout.spec.ts
│ ├── products/
│ │ ├── listing.spec.ts
│ │ └── details.spec.ts
│ └── checkout/
│ └── flow.spec.ts
├── fixtures/
│ └── test-data.ts
└── utils/
└── helpers.ts
Custom Fixtures
// fixtures/test-fixtures.ts
import { test as base } from '@playwright/test';
type Fixtures = {
authenticatedPage: Page;
};
export const test = base.extend<Fixtures>({
authenticatedPage: async ({ page }, use) => {
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password');
await page.click('button[type="submit"]');
await page.waitForURL('/dashboard');
await use(page);
},
});
// Usage
test('authenticated test', async ({ authenticatedPage }) => {
await authenticatedPage.goto('/profile');
// Already logged in
});
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Race conditions | Test checks before page updates | Use waitFor or expect with retries |
| Flaky selectors | CSS classes change | Use data-testid or role selectors |
| Hard-coded waits | page.waitForTimeout(3000) |
Wait for specific conditions |
| Not waiting for hydration | JS not executed | waitForLoadState('networkidle') |
| Shared state | Tests affect each other | Use isolated storage/auth per test |
| Ignoring errors | Uncaught exceptions | Check page.on('pageerror') |
Checklist
- Use stable selectors (test IDs, roles, labels)
- Wait for appropriate conditions (not arbitrary timeouts)
- Test both happy path and error states
- Include accessibility checks
- Test responsive breakpoints
- Mock external API dependencies
- Capture screenshots on failure
- Run tests in CI/CD
- Keep tests independent
- Organize tests by feature
More from vapvarun/claude-backup
php
Modern PHP development best practices including PHP 8.x features, OOP patterns, error handling, security, testing, and performance optimization. Use when writing PHP code, reviewing PHP projects, debugging PHP issues, or implementing PHP features outside of WordPress/Laravel specific contexts.
45laravel
Complete Laravel development guide covering Eloquent, Blade, testing with Pest/PHPUnit, queues, caching, API resources, migrations, and Laravel best practices. Use when building Laravel applications, writing Laravel code, implementing features in Laravel, debugging Laravel issues, or when user mentions Laravel, Eloquent, Blade, Artisan, or PHP frameworks.
23email-marketing
Create email marketing campaigns including newsletters, drip sequences, promotional emails, and transactional emails. Use when writing email copy, designing email templates, or planning email automation.
14javascript
Write modern JavaScript/ES6+ code following best practices for performance, security, and maintainability. Use when writing JS code, fixing bugs, or implementing frontend functionality.
14html-markup
Write semantic, accessible HTML5 markup following best practices for structure, SEO, and accessibility. Use when creating HTML templates, fixing markup issues, or building web page structures.
12landing-page
Create high-converting landing pages with persuasive copy, clear CTAs, social proof, and optimized structure. Use when building sales pages, product pages, lead capture pages, or conversion-focused pages.
12