
Web Testing
- 14 installs
- 7 repo stars
- Updated August 2, 2026
- practicalswan/agent-skills
web-testing is a Claude Code skill for testing & qa.
About
web-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- web-testing
- Testing & QA
- AI-coding skill
Web Testing by the numbers
- 14 all-time installs (skills.sh)
- Ranked #1,494 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/practicalswan/agent-skills --skill web-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 14 |
|---|---|
| repo stars | ★ 7 |
| Last updated | August 2, 2026 |
| Repository | practicalswan/agent-skills ↗ |
How do I helps with testing & qa tasks.?
Helps with testing & qa tasks.
Who is it for?
Best when you're working on testing & qa and need structured help with web testing.
Skip if: Teams with no testing & qa needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with testing & qa tasks., or when web-testing is a claude code skill for testing & qa.
What you get
Structured output aligned to web-testing: web-testing, Testing & QA.
Files
Web Application Testing & Debugging
Comprehensive toolkit for testing and debugging web applications using Playwright automation and Chrome DevTools.
- Leverage native parallel subagent dispatch and 200k+ context windows where available.
Activation Conditions
Use symptom -> action triggers: when one matches, apply this skill and verify with the protocol below.
Playwright Testing:
- Testing frontend functionality in a real browser
- Verifying UI behavior and interactions
- Debugging web application issues
- Capturing screenshots for documentation or debugging
- Inspecting browser console logs
- Validating form submissions and user flows
- Checking responsive design across viewports
Chrome DevTools Debugging:
- Interacting with web pages through automated controls
- Taking screenshots, and analyzing network traffic
- Navigating pages, clicking elements, filling forms, handling dialogs
- Emulating network conditions or devices
- Running JavaScript in page context, capturing console messages
- Performance profiling and identifying bottlenecks
Part 1: Playwright Testing
Core Capabilities
Browser Automation
import { test, expect, Page, Browser } from '@playwright/test';
// Navigate to URLs
await page.goto('https://example.com');
await page.waitForLoadState('networkidle');
// Click buttons and links
await page.click('#submit-button');
await page.click('text=Continue');
// Fill form fields
await page.fill('#email', 'user@example.com');
await page.fill('#password', 'securepassword');
// Select dropdowns
await page.selectOption('#country', 'United States');
// Handle dialogs and alerts
page.on('dialog', async dialog => {
await dialog.accept(); // or dialog.dismiss()
});User Flow Testing
test('complete checkout flow', async ({ page }) => {
// Add to cart
await page.goto('/products');
await page.click('text=Add to Cart');
// Navigate to cart
await page.goto('/cart');
// Verify item in cart
await expect(page.locator('.cart-item')).toHaveCount(1);
// Checkout
await page.click('text=Checkout');
await page.fill('#email', 'test@example.com');
await page.fill('#shipping-address', '123 Main St');
await page.click('text=Place Order');
// Verify success
await expect(page.locator('.success-message')).toBeVisible();
});Form Validation Testing
test('form validation', async ({ page }) => {
await page.goto('/register');
// Submit empty form - should show errors
await page.click('text=Submit');
// Check error messages
await expect(page.locator('.error-email')).toBeVisible();
await expect(page.locator('.error-password')).toBeVisible();
// Fill valid data
await page.fill('#email', 'valid@example.com');
await page.fill('#password', 'securePass123!');
await page.click('text=Submit');
// Verify no errors and success
await expect(page.locator('.error-email')).not.toBeVisible();
await expect(page.locator('.success-message')).toBeVisible();
});Responsive Testing
test.describe('Responsive Design', () => {
const viewports = [
{ name: 'Mobile', width: 375, height: 667 },
{ name: 'Tablet', width: 768, height: 1024 },
{ name: 'Desktop', width: 1280, height: 720 },
];
viewports.forEach(({ name, width, height }) => {
test(`layout on ${name} (${width}x${height})`, async ({ page }) => {
await page.setViewportSize({ width, height });
await page.goto('/');
// Check navigation is visible and accessible
const nav = page.locator('nav');
await expect(nav).toBeVisible();
// On mobile, check hamburger menu is present
if (width < 768) {
await expect(page.locator('.mobile-menu-toggle')).toBeVisible();
} else {
await expect(page.locator('.mobile-menu-toggle')).not.toBeVisible();
}
// Screenshot for comparison
await page.screenshot({
path: `screenshots/${name.toLowerCase()}-layout.png`,
fullPage: true,
});
});
});
});Console & Network Inspection
test('console errors and warnings', async ({ page, context }) => {
const errors: string[] = [];
// Listen for console errors
page.on('console', msg => {
if (msg.type() === 'error') {
errors.push(msg.text());
}
});
await page.goto('/');
// Assertions
expect(errors).toEqual([]);
});
test('network requests monitoring', async ({ page }) => {
const requests: string[] = [];
page.on('request', request => {
requests.push(request.url());
});
await page.goto('/');
// Verify API calls
const apiRequests = requests.filter(url => url.includes('/api/'));
expect(apiRequests.length).toBeGreaterThan(0);
// Verify no 404s
const failedResponses: any[] = [];
page.on('response', response => {
if (response.status() === 404) {
failedResponses.push(response.url());
}
});
await page.click('a[href="/about"]');
expect(failedResponses).toEqual([]);
});Accessibility Testing
test('basic accessibility checks', async ({ page }) => {
// Check heading hierarchy
const headings = await page.locator('h1, h2, h3').all();
expect(headings[0]).toHaveText('Main Heading'); // h1 should be first
// Check images have alt text
const imagesWithoutAlt = await page.locator('img:not([alt])').count();
expect(imagesWithoutAlt).toBe(0);
// Check form labels
const inputs = await page.locator('input, select, textarea').all();
for (const input of inputs) {
const hasLabel = await input.evaluate(el => {
return el.labels.length > 0 || el.getAttribute('aria-label');
});
expect(hasLabel).toBeTruthy();
}
// Check keyboard navigation
await page.keyboard.press('Tab');
const focusedElement = await page.evaluate(() => document.activeElement?.tagName);
expect(['INPUT', 'BUTTON', 'A']).toContain(focusedElement);
});Visual Regression Testing
import { compareScreenshots } from './visual-utils';
test('visual regression - home page', async ({ page }) => {
await page.goto('/');
// Wait for all images and fonts to load
await page.waitForLoadState('networkidle');
await page.waitForTimeout(1000);
// Take screenshot
const screenshot = await page.screenshot({
fullPage: true,
});
// Compare with baseline
const diff = await compareScreenshots(screenshot, 'baseline/home.png');
expect(diff.pixelDifference).toBeLessThan(100); // Threshold
});---
Part 2: Chrome DevTools Integration
Tool Categories
Navigation & Page Management
// Open new page
await chrome.newPage();
// Navigate to URL
await chrome.navigatePage('https://example.com');
// Reload current page
await chrome.navigatePage({ action: 'reload' });
// Navigate history
await chrome.navigatePage({ action: 'back' });
await chrome.navigatePage({ action: 'forward' });
// List all open pages
const pages = await chrome.listPages();
await chrome.selectPage(pages[0].id);
// Close specific page
await chrome.closePage('page-id-here');
// Wait for text to appear
await chrome.waitFor('Welcome to the site');Input & Interaction
// Take snapshot to get element IDs
const snapshot = await chrome.takeSnapshot();
// Find element by uid
const submitButton = snapshot.elements.find(el => el.text === 'Submit');
// Click element
await chrome.click(submitButton.uid);
// Fill single field
await chrome.fill(inputUid, 'value@example.com');
// Fill multiple fields at once
await chrome.fillForm([
{ uid: emailInputUid, value: 'test@example.com' },
{ uid: passwordInputUid, value: 'password123' },
{ uid: nameInputUid, value: 'John Doe' },
]);
// Hover over element
await chrome.hover(buttonUid);
// Press keyboard shortcuts
await chrome.pressKey('Enter');
await chrome.pressKey('Control+C');
// Drag and drop
await chrome.drag(sourceUid, targetUid);
// Handle browser dialogs
await chrome.handleDialog('accept');
await chrome.handleDialog('dismiss');Debugging & Inspection
// Get accessibility tree (best for finding elements)
const snapshot = await chrome.takeSnapshot();
// Take visual screenshot
const screenshot = await chrome.takeScreenshot();
// List all console messages
const messages = await chrome.listConsoleMessages();
// Get messages by level
const errors = await chrome.listConsoleMessages('error');
const warnings = await chrome.listConsoleMessages('warning');
// Get specific message details
const message = await chrome.getConsoleMessage(messageId);
// Evaluate JavaScript in page context
const result = await chrome.evaluateScript('document.title');
const userInfo = await chrome.evaluateScript(`
JSON.parse(localStorage.getItem('user'))
`);
// List network requests
const requests = await chrome.listNetworkRequests();
// Get failed requests
const failedRequests = requests.filter(req =>
req.status >= 400 || req.status === 0
);
// Get specific request details
const requestDetails = await chrome.getNetworkRequest(requestId);Emulation & Performance
// Resize viewport
await chrome.resizePage({ width: 375, height: 667 }); // Mobile
// Emulate network conditions
await chrome.emulate({
network: 'offline' // 'slow-3g', 'fast-3g', 'online'
});
// Emulate geolocation
await chrome.emulate({
geolocation: { lat: 40.7128, lon: -74.0060 }
});
// Start performance trace
await chrome.performanceStartTrace({ reload: true });
// Stop trace after navigation
await chrome.waitFor('Page loaded');
const trace = await chrome.performanceStopTrace();
// Get insights
const insights = await chrome.performanceAnalyzeInsight();
console.log('LCP:', insights.largestContentfulPaint);
console.log('CLS:', insights.cumulativeLayoutShift);Common Debugging Patterns
Pattern A: Identifying Elements (Snapshot-First)
Always prefer snapshot over screenshot for finding elements:
// 1. Get current page structure
const snapshot = await chrome.takeSnapshot();
// 2. Find the target element by its uid
const element = snapshot.elements.find(el => el.text === 'Continue');
// 3. Use the uid for interaction
await chrome.click(element.uid);Pattern B: Troubleshooting Errors
When a page is failing, check both console and network:
// 1. Check console messages for JavaScript errors
const errors = await chrome.listConsoleMessages('error');
console.log('JavaScript Errors:', errors);
// 2. Check network requests for failures
const requests = await chrome.listNetworkRequests();
const failed = requests.filter(r => r.status >= 400);
console.log('Failed Requests:', failed);
// 3. Check specific values via JavaScript
const apiResponse = await chrome.evaluateScript(`
window.lastApiResponse
`);
console.log('Last API Response:', apiResponse);Pattern C: Performance Profiling
Identify why a page is slow:
// 1. Start performance trace with reload
await chrome.performanceStartTrace({ reload: true, autoStop: true });
// 2. Wait for trace to complete
const timeout = 10000;
await new Promise(resolve => setTimeout(resolve, timeout));
// 3. Get performance insights
const insights = await chrome.performanceAnalyzeInsight();
console.log('Performance Issues:', insights.issues);
console.log('LCP:', insights.largestContentfulPaint);
console.log('CLS:', insights.cumulativeLayoutShift);
console.log('FID:', insights.firstInputDelay);
// 4. Identify bottlenecks
if (insights.largestContentfulPaint > 2500) {
console.warn('LCP is slow - consider optimizing images and CSS');
}
if (insights.cumulativeLayoutShift > 0.1) {
console.warn('CLS is high - avoid layout shifts');
}Part 3: Testing Best Practices
Test Structure
test.describe('User Authentication', () => {
test.beforeEach(async ({ page }) => {
// Setup: login fresh each test
await page.goto('/login');
});
test('successful login with valid credentials', async ({ page }) => {
await test.step('Enter credentials', async () => {
await page.fill('#email', 'valid@example.com');
await page.fill('#password', 'correct-password');
});
await test.step('Submit form', async () => {
await page.click('text=Login');
});
await test.step('Verify redirected to dashboard', async () => {
await expect(page).toHaveURL('/dashboard');
});
});
test('shows error for invalid credentials', async ({ page }) => {
await page.fill('#email', 'invalid@example.com');
await page.fill('#password', 'wrong-password');
await page.click('text=Login');
await expect(page.locator('.error-message')).toBeVisible();
await expect(page).toHaveURL('/login');
});
});Page Object Model
// pages/LoginPage.ts
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login');
}
async login(email: string, password: string) {
await this.page.fill('#email', email);
await this.page.fill('#password', password);
await this.page.click('text=Login');
}
async getErrorMessage() {
return await this.page.locator('.error-message').textContent();
}
assertVisible() {
expect(this.page.locator('h1')).toHaveText('Login');
}
}
// Tests
test('login flow', async ({ page }) => {
const loginPage = new LoginPage(page);
loginPage.goto();
await loginPage.login('user@example.com', 'password');
await expect(page).toHaveURL('/dashboard');
});Parallel Testing
// playwright.config.ts
export default defineConfig({
workers: process.env.CI ? 2 : 4, // Parallel execution
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
{
name: 'firefox',
use: { browserName: 'firefox' },
},
{
name: 'webkit',
use: { browserName: 'webkit' },
},
],
});---
Part 4: Debugging Toolset
Quick Reference
| Task | Playwright | Chrome DevTools |
|---|---|---|
| Browser Automation | Yes | Yes |
| Page Navigation | page.goto() | navigate_page() |
| Click Elements | page.click() | click(uid) |
| Fill Forms | page.fill() | fill(uid, value) |
| Screenshots | page.screenshot() | take_screenshot() |
| Console Logs | page.on('console') | list_console_messages() |
| Network Requests | page.on('request') | list_network_requests() |
| JavaScript Eval | page.evaluate() | evaluate_script() |
| Viewport Resize | page.setViewportSize() | resize_page() |
| Performance | Trace API | performance_* tools |
| Device Emulation | deviceDescriptor | emulate() |
Common Debugging Commands
# Run Playwright tests
npx playwright test
# Run tests with UI (helps debugging)
npx playwright test --ui
# Run tests in headed mode (watch browser)
npx playwright test --headed
# Debug specific test
npx playwright test tests/login.spec.ts --debug
# Generate codegen from browser actions
npx playwright codegen https://example.com---
Anti-Patterns
- Starting without a clear success condition: The skill becomes advice-shaped instead of workflow-shaped.
- Skipping the bundled references or scripts: You lose the proven path the catalog is trying to preserve.
- Claiming completion without concrete evidence: A future agent or reviewer cannot trust the result or resume the work safely.
Verification Protocol
Before claiming "skill applied successfully":
1. Pass/fail: The Web Testing implementation names the target runtime, framework version, and affected files. 2. Pass/fail: Build, lint, test, or equivalent local validation is run for the changed surface. 3. Pass/fail: Edge cases for errors, dependency drift, and environment differences are addressed or explicitly out of scope. 4. Pressure-test scenario: Apply the workflow to a change that passes happy-path tests but fails one boundary condition. 5. Success metric: Zero untested success claims; every implementation claim maps to a command or artifact.
Testing Checklist
Functionality
- [ ] All user flows work end-to-end
- [ ] Form validation tested for success and failure cases
- [ ] Error handling verified
- [ ] Edge cases covered
Responsive Design
- [ ] Tested on mobile (375px, 414px)
- [ ] Tested on tablet (768px, 1024px)
- [ ] Tested on desktop (1280px, 1440px)
- [ ] Navigation accessible on mobile
- [ ] No horizontal scrollbars
Cross-Browser
- [ ] Tested in Chrome
- [ ] Tested in Firefox
- [ ] Tested in Safari/WebKit
- [ ] Tested in Edge
Accessibility
- [ ] All interactive elements keyboard accessible
- [ ] Focus states visible
- [ ] ARIA labels on icon-only buttons
- [ ] Form fields have labels
- [ ] Images have alt text (except decorative)
- [ ] Touch targets ≥ 44x44px on mobile
Performance
- [ ] Page load time < 3 seconds
- [ ] LCP < 2.5 seconds
- [ ] CLS < 0.1
- [ ] No layout shifts on interaction
- [ ] Images optimized (WebP, lazy load)
Error Handling
- [ ] Console errors logged and reviewed
- [ ] Failed network requests identified
- [ ] 404s checked and fixed
- [ ] 500 errors investigated
- [ ] User-friendly error messages shown
---
References & Resources
Documentation
- Playwright Selectors — All selector types with decision tree and priority order
- Test Patterns — Page Object Model, fixtures, auth reuse, API mocking, and accessibility patterns
Scripts
- Test Scaffold — PowerShell Playwright test file generator for e2e, visual, and accessibility tests
Examples
- E2E Recipe App Tests — Kitchen Odyssey test suite with Page Objects and CI configuration
---
<!-- PORTABILITY:START -->
Cross-Client Portability
This skill is written to stay usable across GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- GitHub Copilot: keep the folder in a Copilot-visible skill or plugin path, or wrap the workflow as project instructions if the host does not support portable skill folders directly.
- Claude Code: keep the folder in a local skills directory or a compatible plugin or marketplace source.
- Codex: install or sync the folder into
$CODEX_HOME/skills/<skill-name>and restart Codex after major changes. - Gemini CLI: this repository generates a project command named
/skills:web-testingfrom this skill. Rebuild commands withpython scripts/export-gemini-skill.py web-testingand then run/commands reloadinside Gemini CLI.
<!-- PORTABILITY:END -->
<!-- MCP:START -->
MCP Availability And Fallback
Preferred MCP Server: Playwright MCP
- Fallback prompt: "Use the Web Application Testing & Debugging skill without MCP. Rely on the local
SKILL.md, bundled references or scripts, and manual verification. Show the exact commands, evidence, and final checks you used before concluding." - Use Playwright CLI (
npx playwright test, headed mode, or codegen) and browser devtools when MCP browser tools are unavailable. - Keep screenshots, console logs, and network traces as test evidence when reproducing issues manually.
<!-- MCP:END -->
Related Skills
- development-workflow: Use it when the workflow also needs planning, quality gates, and delivery tracking.
- documentation-quality: Use it when the workflow also needs documentation review standards and quality gates.
- verification-before-completion: Use it when the workflow also needs final evidence checks before claiming completion.
- code-quality: Use it when the workflow also needs two-stage review (spec compliance first, then code quality), maintainability, and refactoring guidance.
Changelog
[2026-04-25] - Version 1.2 Verification Protocol Refresh
Added
- Added a
Verification Protocolsection with skill-specific pass/fail checks, one pressure-test scenario, and a measurable success metric. - Added guidance to leverage native parallel subagent dispatch and 200k+ context windows where available.
Changed
- Updated
SKILL.mdfrontmatter toversion: "1.2"andlast_updated: 2026-04-25. - Reframed activation guidance toward symptom -> action triggers and standardized two-stage review wording where applicable.
[2026-04-24] - Version 1.1 Refresh
Changed
- Updated the SKILL frontmatter version to
1.1for the 2026-04-24 catalog refresh.
[2026-04-24] - Skill Refresh
Changed
- Standardized the SKILL frontmatter with version metadata, last-updated date, tags, and a concise catalog description.
- Reformatted the portability and MCP guidance with a preferred server line, a copy-paste fallback prompt, and consistent bullet lists.
- Added a catalog-standard Anti-Patterns section and refreshed the Related Skills links at the end of the skill.
[2026-04-24] - Catalog Audit Cleanup
Fixed
- Removed obsolete standalone Skill Paths guidance that duplicated the generated portability section.
All notable changes to this skill will be documented in this file.
[2026-04-04] - Cross-Client Portability Refresh
Changed
- Added a standard portability note covering GitHub Copilot, Claude Code, Codex, and Gemini CLI.
- Documented the preferred MCP server surface for this skill and a local no-MCP fallback workflow.
Tested
- Validated
SKILL.mdfrontmatter, portability sections, and Gemini export readiness withpython scripts/validate-skills.py.
[2026-03-09] - Workspace Modernization
Added
- Added a 2026-03-09 maintenance entry after reviewing the skill; the earlier unit-test activation improvements remained the only content changes needed.
[2026-03-01] — Activation Fix
Fixed
- Added "unit tests" alongside E2E — previously only activated for Playwright/E2E testing prompts
- Changed "E2E tests" to "E2E/unit tests" in description
[2026-02-28] — Description Rewrite & Cross-References
Changed
- Rewrote skill description to ~200 characters with clear, specific activation keywords
- Improved keyword specificity to reduce overlap with related skills
Added
## Related Skillscross-reference table with 2-4 related skills and "Use When" guidance
E2E Test Suite Example: Kitchen Odyssey Recipe App
Complete Playwright test suite for a recipe application with authentication, CRUD operations, search, and admin features.
---
Project Structure
tests/
pages/
LoginPage.ts
HomePage.ts
RecipeCreatePage.ts
RecipeDetailPage.ts
SearchPage.ts
ProfilePage.ts
AdminPage.ts
fixtures/
auth.ts
test-data.ts
auth.setup.ts
login.spec.ts
recipe-create.spec.ts
recipe-search.spec.ts
recipe-detail.spec.ts
profile.spec.ts
admin.spec.ts
playwright.config.ts
.github/workflows/playwright.yml---
Page Object Classes
LoginPage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly signInButton: Locator
readonly signUpLink: Locator
readonly errorAlert: Locator
readonly guestButton: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.signInButton = page.getByRole('button', { name: 'Sign In' })
this.signUpLink = page.getByRole('link', { name: 'Sign Up' })
this.errorAlert = page.getByRole('alert')
this.guestButton = page.getByRole('button', { name: /guest/i })
}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.signInButton.click()
}
async expectError(message: string) {
await expect(this.errorAlert).toContainText(message)
}
async expectRedirectToDashboard() {
await expect(this.page).toHaveURL('/')
}
}HomePage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class HomePage {
readonly page: Page
readonly heading: Locator
readonly recipeCards: Locator
readonly searchInput: Locator
readonly createButton: Locator
readonly categoryTabs: Locator
constructor(page: Page) {
this.page = page
this.heading = page.getByRole('heading', { level: 1 })
this.recipeCards = page.locator('[data-testid="recipe-card"]')
this.searchInput = page.getByPlaceholder(/search/i)
this.createButton = page.getByRole('link', { name: /create/i })
this.categoryTabs = page.getByRole('tablist')
}
async goto() {
await this.page.goto('/')
}
async expectRecipeCount(count: number) {
await expect(this.recipeCards).toHaveCount(count)
}
async clickRecipe(title: string) {
await this.recipeCards.filter({ hasText: title }).click()
}
async searchRecipes(query: string) {
await this.searchInput.fill(query)
await this.searchInput.press('Enter')
}
}RecipeCreatePage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class RecipeCreatePage {
readonly page: Page
readonly titleInput: Locator
readonly descriptionInput: Locator
readonly ingredientsInput: Locator
readonly instructionsInput: Locator
readonly timeInput: Locator
readonly difficultySelect: Locator
readonly submitButton: Locator
readonly successMessage: Locator
constructor(page: Page) {
this.page = page
this.titleInput = page.getByLabel('Title')
this.descriptionInput = page.getByLabel('Description')
this.ingredientsInput = page.getByLabel('Ingredients')
this.instructionsInput = page.getByLabel('Instructions')
this.timeInput = page.getByLabel(/time|duration/i)
this.difficultySelect = page.getByLabel('Difficulty')
this.submitButton = page.getByRole('button', { name: /create|submit|save/i })
this.successMessage = page.getByText(/created|saved/i)
}
async goto() {
await this.page.goto('/recipes/create')
}
async fillRecipe(recipe: {
title: string
description: string
ingredients: string
instructions: string
time?: string
difficulty?: string
}) {
await this.titleInput.fill(recipe.title)
await this.descriptionInput.fill(recipe.description)
await this.ingredientsInput.fill(recipe.ingredients)
await this.instructionsInput.fill(recipe.instructions)
if (recipe.time) await this.timeInput.fill(recipe.time)
if (recipe.difficulty) {
await this.difficultySelect.selectOption(recipe.difficulty)
}
}
async submit() {
await this.submitButton.click()
}
async expectSuccess() {
await expect(this.successMessage).toBeVisible()
}
}RecipeDetailPage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class RecipeDetailPage {
readonly page: Page
readonly title: Locator
readonly description: Locator
readonly ingredients: Locator
readonly instructions: Locator
readonly authorName: Locator
readonly editButton: Locator
readonly deleteButton: Locator
readonly backButton: Locator
constructor(page: Page) {
this.page = page
this.title = page.getByRole('heading', { level: 1 })
this.description = page.locator('[data-testid="recipe-description"]')
this.ingredients = page.locator('[data-testid="ingredients"]')
this.instructions = page.locator('[data-testid="instructions"]')
this.authorName = page.locator('[data-testid="author"]')
this.editButton = page.getByRole('button', { name: /edit/i })
this.deleteButton = page.getByRole('button', { name: /delete/i })
this.backButton = page.getByRole('link', { name: /back/i })
}
async goto(recipeId: string) {
await this.page.goto(`/recipes/${recipeId}`)
}
async expectTitle(title: string) {
await expect(this.title).toHaveText(title)
}
async deleteRecipe() {
await this.deleteButton.click()
const confirmDialog = this.page.getByRole('dialog')
await confirmDialog.getByRole('button', { name: /confirm|yes|delete/i }).click()
}
}SearchPage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class SearchPage {
readonly page: Page
readonly searchInput: Locator
readonly results: Locator
readonly resultCount: Locator
readonly noResults: Locator
readonly filters: Locator
constructor(page: Page) {
this.page = page
this.searchInput = page.getByRole('searchbox')
this.results = page.locator('[data-testid="search-result"]')
this.resultCount = page.locator('[data-testid="result-count"]')
this.noResults = page.getByText(/no results|no recipes found/i)
this.filters = page.locator('[data-testid="search-filters"]')
}
async goto() {
await this.page.goto('/search')
}
async search(query: string) {
await this.searchInput.fill(query)
await this.searchInput.press('Enter')
await this.page.waitForLoadState('networkidle')
}
async expectResultCount(count: number) {
await expect(this.results).toHaveCount(count)
}
async expectNoResults() {
await expect(this.noResults).toBeVisible()
}
}ProfilePage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class ProfilePage {
readonly page: Page
readonly displayName: Locator
readonly email: Locator
readonly recipeCount: Locator
readonly userRecipes: Locator
readonly editProfileButton: Locator
constructor(page: Page) {
this.page = page
this.displayName = page.getByRole('heading', { level: 1 })
this.email = page.locator('[data-testid="user-email"]')
this.recipeCount = page.locator('[data-testid="recipe-count"]')
this.userRecipes = page.locator('[data-testid="user-recipe"]')
this.editProfileButton = page.getByRole('button', { name: /edit profile/i })
}
async goto() {
await this.page.goto('/profile')
}
async expectDisplayName(name: string) {
await expect(this.displayName).toContainText(name)
}
}AdminPage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class AdminPage {
readonly page: Page
readonly userTable: Locator
readonly recipeTable: Locator
readonly statsCards: Locator
readonly tabs: Locator
constructor(page: Page) {
this.page = page
this.userTable = page.locator('[data-testid="user-table"]')
this.recipeTable = page.locator('[data-testid="recipe-table"]')
this.statsCards = page.locator('[data-testid="stat-card"]')
this.tabs = page.getByRole('tablist')
}
async goto() {
await this.page.goto('/admin')
}
async switchTab(name: string) {
await this.tabs.getByRole('tab', { name }).click()
}
async expectStatCount(min: number) {
const count = await this.statsCards.count()
expect(count).toBeGreaterThanOrEqual(min)
}
}---
Shared Fixtures
fixtures/auth.ts
import { test as base } from '@playwright/test'
import { LoginPage } from '../pages/LoginPage'
import { HomePage } from '../pages/HomePage'
import { RecipeCreatePage } from '../pages/RecipeCreatePage'
import { RecipeDetailPage } from '../pages/RecipeDetailPage'
import { SearchPage } from '../pages/SearchPage'
import { ProfilePage } from '../pages/ProfilePage'
import { AdminPage } from '../pages/AdminPage'
type Pages = {
loginPage: LoginPage
homePage: HomePage
recipeCreatePage: RecipeCreatePage
recipeDetailPage: RecipeDetailPage
searchPage: SearchPage
profilePage: ProfilePage
adminPage: AdminPage
}
export const test = base.extend<Pages>({
loginPage: async ({ page }, use) => use(new LoginPage(page)),
homePage: async ({ page }, use) => use(new HomePage(page)),
recipeCreatePage: async ({ page }, use) => use(new RecipeCreatePage(page)),
recipeDetailPage: async ({ page }, use) => use(new RecipeDetailPage(page)),
searchPage: async ({ page }, use) => use(new SearchPage(page)),
profilePage: async ({ page }, use) => use(new ProfilePage(page)),
adminPage: async ({ page }, use) => use(new AdminPage(page)),
})
export { expect } from '@playwright/test'fixtures/test-data.ts
export const users = {
regular: {
email: 'testuser@kitchen-odyssey.com',
password: 'TestPass123!',
name: 'Test User',
},
admin: {
email: 'admin@kitchen-odyssey.com',
password: 'AdminPass123!',
name: 'Admin User',
},
}
export const recipes = {
pasta: {
title: `Test Pasta ${Date.now()}`,
description: 'Creamy garlic pasta for E2E testing',
ingredients: '200g pasta\n2 cloves garlic\n100ml cream\nParmesan',
instructions: '1. Boil pasta\n2. Saute garlic\n3. Add cream\n4. Toss and serve',
time: '25',
difficulty: 'Easy',
},
salad: {
title: `Test Salad ${Date.now()}`,
description: 'Fresh garden salad for E2E testing',
ingredients: 'Lettuce\nTomatoes\nCucumber\nOlive oil',
instructions: '1. Wash vegetables\n2. Chop\n3. Toss with oil\n4. Season and serve',
time: '10',
difficulty: 'Easy',
},
}
export function uniqueRecipe(base = recipes.pasta) {
return { ...base, title: `${base.title.split(' ').slice(0, 2).join(' ')} ${Date.now()}` }
}---
Authentication Setup
auth.setup.ts
import { test as setup, expect } from '@playwright/test'
import { users } from './fixtures/test-data'
setup('authenticate as regular user', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(users.regular.email)
await page.getByLabel('Password').fill(users.regular.password)
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page).toHaveURL('/')
await page.context().storageState({ path: '.auth/user.json' })
})
setup('authenticate as admin', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill(users.admin.email)
await page.getByLabel('Password').fill(users.admin.password)
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page).toHaveURL('/')
await page.context().storageState({ path: '.auth/admin.json' })
})---
Test Suites
login.spec.ts
import { test, expect } from './fixtures/auth'
import { users } from './fixtures/test-data'
test.describe('Login Flow', () => {
test('successful login redirects to home', async ({ loginPage }) => {
await loginPage.goto()
await loginPage.login(users.regular.email, users.regular.password)
await loginPage.expectRedirectToDashboard()
})
test('invalid credentials show error', async ({ loginPage }) => {
await loginPage.goto()
await loginPage.login('wrong@email.com', 'wrongpassword')
await loginPage.expectError('Invalid')
})
test('empty form shows validation errors', async ({ loginPage }) => {
await loginPage.goto()
await loginPage.signInButton.click()
await expect(loginPage.page.getByText(/required|email/i)).toBeVisible()
})
test('sign up link navigates to registration', async ({ loginPage }) => {
await loginPage.goto()
await loginPage.signUpLink.click()
await expect(loginPage.page).toHaveURL('/signup')
})
test('guest mode allows browsing without login', async ({ loginPage }) => {
await loginPage.goto()
await loginPage.guestButton.click()
await expect(loginPage.page).toHaveURL('/')
})
})recipe-create.spec.ts
import { test, expect } from './fixtures/auth'
import { uniqueRecipe } from './fixtures/test-data'
test.use({ storageState: '.auth/user.json' })
test.describe('Recipe Creation', () => {
test('creates a new recipe successfully', async ({ recipeCreatePage }) => {
const recipe = uniqueRecipe()
await recipeCreatePage.goto()
await recipeCreatePage.fillRecipe(recipe)
await recipeCreatePage.submit()
await recipeCreatePage.expectSuccess()
})
test('validates required fields', async ({ recipeCreatePage }) => {
await recipeCreatePage.goto()
await recipeCreatePage.submit()
await expect(recipeCreatePage.page.getByText(/required/i).first()).toBeVisible()
})
test('preserves form data on validation error', async ({ recipeCreatePage }) => {
await recipeCreatePage.goto()
await recipeCreatePage.titleInput.fill('Partial Recipe')
await recipeCreatePage.submit()
await expect(recipeCreatePage.titleInput).toHaveValue('Partial Recipe')
})
test('navigates to recipe detail after creation', async ({ recipeCreatePage, page }) => {
const recipe = uniqueRecipe()
await recipeCreatePage.goto()
await recipeCreatePage.fillRecipe(recipe)
await recipeCreatePage.submit()
await expect(page).toHaveURL(/\/recipes\//)
await expect(page.getByRole('heading', { level: 1 })).toContainText(recipe.title)
})
})recipe-search.spec.ts
import { test, expect } from './fixtures/auth'
test.use({ storageState: '.auth/user.json' })
test.describe('Recipe Search', () => {
test('search returns matching recipes', async ({ searchPage }) => {
await searchPage.goto()
await searchPage.search('pasta')
const count = await searchPage.results.count()
expect(count).toBeGreaterThan(0)
})
test('search with no matches shows empty state', async ({ searchPage }) => {
await searchPage.goto()
await searchPage.search('xyznonexistentrecipe123')
await searchPage.expectNoResults()
})
test('search is case-insensitive', async ({ searchPage }) => {
await searchPage.goto()
await searchPage.search('PASTA')
const count = await searchPage.results.count()
expect(count).toBeGreaterThan(0)
})
test('clicking a search result navigates to detail', async ({ searchPage, page }) => {
await searchPage.goto()
await searchPage.search('pasta')
await searchPage.results.first().click()
await expect(page).toHaveURL(/\/recipes\//)
})
})recipe-detail.spec.ts
import { test, expect } from './fixtures/auth'
test.use({ storageState: '.auth/user.json' })
test.describe('Recipe Detail View', () => {
test.beforeEach(async ({ homePage }) => {
await homePage.goto()
})
test('displays recipe information', async ({ homePage, page }) => {
await homePage.recipeCards.first().click()
await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
})
test('shows ingredients and instructions', async ({ homePage, recipeDetailPage, page }) => {
await homePage.recipeCards.first().click()
await expect(recipeDetailPage.ingredients).toBeVisible()
await expect(recipeDetailPage.instructions).toBeVisible()
})
test('back button returns to previous page', async ({ homePage, recipeDetailPage, page }) => {
await homePage.recipeCards.first().click()
await recipeDetailPage.backButton.click()
await expect(page).toHaveURL('/')
})
})profile.spec.ts
import { test, expect } from './fixtures/auth'
import { users } from './fixtures/test-data'
test.use({ storageState: '.auth/user.json' })
test.describe('Profile Page', () => {
test('displays user information', async ({ profilePage }) => {
await profilePage.goto()
await profilePage.expectDisplayName(users.regular.name)
})
test('shows user recipes', async ({ profilePage }) => {
await profilePage.goto()
await expect(profilePage.userRecipes.first()).toBeVisible()
})
test('edit profile button is accessible', async ({ profilePage }) => {
await profilePage.goto()
await expect(profilePage.editProfileButton).toBeVisible()
await expect(profilePage.editProfileButton).toBeEnabled()
})
})admin.spec.ts
import { test, expect } from './fixtures/auth'
test.use({ storageState: '.auth/admin.json' })
test.describe('Admin Functions', () => {
test('admin dashboard loads with stats', async ({ adminPage }) => {
await adminPage.goto()
await adminPage.expectStatCount(2)
})
test('user management tab shows user list', async ({ adminPage }) => {
await adminPage.goto()
await adminPage.switchTab('Users')
await expect(adminPage.userTable).toBeVisible()
})
test('recipe management tab shows recipes', async ({ adminPage }) => {
await adminPage.goto()
await adminPage.switchTab('Recipes')
await expect(adminPage.recipeTable).toBeVisible()
})
test('non-admin user cannot access admin page', async ({ page }) => {
// Use regular user auth for this test
test.use({ storageState: '.auth/user.json' })
await page.goto('/admin')
await expect(page).not.toHaveURL('/admin')
})
})---
Configuration
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 ? 4 : undefined,
reporter: [
['html', { open: 'never' }],
['list'],
...(process.env.CI ? [['github' as const]] : []),
],
use: {
baseURL: process.env.BASE_URL || 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
// Auth setup — runs first
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// Authenticated tests
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
dependencies: ['setup'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
dependencies: ['setup'],
},
{
name: 'mobile',
use: { ...devices['iPhone 14'] },
dependencies: ['setup'],
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
}).github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
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
- name: Run Playwright tests
run: npx playwright test
- name: Upload test report
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 14
- name: Upload test results
uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results
path: test-results/
retention-days: 7MIT License
Copyright (c) 2026 Sithu Win San
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.Playwright Selectors Reference
Complete reference for locating elements in Playwright tests.
---
Selector Types
Role Selectors (Preferred)
Use ARIA roles for accessible, resilient selectors.
// By role
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { name: 'Welcome', level: 1 })
page.getByRole('link', { name: 'Sign up' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('checkbox', { name: 'Remember me' })
page.getByRole('combobox', { name: 'Country' })
page.getByRole('tab', { name: 'Settings' })
page.getByRole('navigation')
page.getByRole('dialog', { name: 'Confirm delete' })
page.getByRole('alert')
page.getByRole('listitem')
page.getByRole('row', { name: 'John Doe' })
page.getByRole('cell', { name: '42' })
// Common roles: button, link, heading, textbox, checkbox, radio,
// combobox, listbox, option, tab, tabpanel, dialog, alert, alertdialog,
// navigation, main, complementary, contentinfo, banner, form, list, listitem,
// table, row, cell, columnheader, rowheader, img, progressbar, slider,
// spinbutton, status, switch, tooltip, treegrid, tree, treeitemText Selectors
// Exact text match
page.getByText('Welcome back')
// Substring match
page.getByText('Welcome', { exact: false })
// Regular expression
page.getByText(/welcome/i)
// Specific element with text
page.locator('h1').filter({ hasText: 'Dashboard' })Label Selectors
// By associated <label>
page.getByLabel('Email address')
page.getByLabel('Password')
page.getByLabel(/email/i)Placeholder Selectors
page.getByPlaceholder('Search recipes...')
page.getByPlaceholder(/search/i)Alt Text Selectors
page.getByAltText('Company logo')
page.getByAltText(/logo/i)Title Selectors
page.getByTitle('Close dialog')
page.getByTitle(/close/i)Test ID Selectors
// Matches data-testid attribute by default
page.getByTestId('recipe-card')
page.getByTestId('submit-button')
// Configure custom attribute in playwright.config.ts:
// use: { testIdAttribute: 'data-test' }CSS Selectors
page.locator('.recipe-card')
page.locator('#main-content')
page.locator('div.card > h3')
page.locator('[data-status="active"]')
page.locator('input[type="email"]')
page.locator('button.primary:not(:disabled)')
page.locator('ul > li:first-child')
page.locator('.sidebar nav a')XPath Selectors
page.locator('xpath=//button[contains(text(), "Submit")]')
page.locator('xpath=//div[@class="card"]//h3')
page.locator('xpath=//table//tr[position()>1]')---
Filtering and Chaining
Filter by Text
page.locator('.card').filter({ hasText: 'Pasta' })
page.locator('li').filter({ hasText: /vegetarian/i })
// Exclude text
page.locator('.card').filter({ hasNotText: 'Draft' })Filter by Child Element
// Cards that contain a specific badge
page.locator('.card').filter({
has: page.getByText('Featured')
})
// Rows that contain a specific button
page.locator('tr').filter({
has: page.getByRole('button', { name: 'Edit' })
})
// Exclude cards with a delete button
page.locator('.card').filter({
hasNot: page.getByRole('button', { name: 'Delete' })
})Chaining Locators
// Narrow down step by step
const sidebar = page.locator('.sidebar')
const navLinks = sidebar.getByRole('link')
const activeLink = navLinks.filter({ hasText: 'Dashboard' })
// Within a specific section
page.locator('section.recipes').getByRole('heading', { name: 'Popular' })
// Form within a dialog
const dialog = page.getByRole('dialog')
dialog.getByLabel('Recipe name').fill('New Recipe')
dialog.getByRole('button', { name: 'Save' }).click()Nth Selectors
// First, last, nth
page.locator('.card').first()
page.locator('.card').last()
page.locator('.card').nth(2) // zero-indexed
// Nth from Playwright selector engine
page.locator('.card >> nth=0')
page.locator('.card >> nth=-1') // last---
Advanced Selectors
Shadow DOM
// Piercing shadow DOM (auto-pierced by Playwright CSS engine)
page.locator('my-component').locator('button')
// Explicit CSS piercing
page.locator('css=my-component >> css=button')Frame Selectors
// By name or URL
const frame = page.frameLocator('#payment-iframe')
frame.getByRole('textbox', { name: 'Card number' }).fill('4242...')
// Nested frames
page.frameLocator('#outer').frameLocator('#inner').getByRole('button')Combining Multiple Conditions
// Locator that matches ALL conditions (AND)
page.getByRole('button', { name: 'Save' }).and(page.locator('.primary'))
// Locator that matches ANY condition (OR)
page.getByRole('button', { name: 'Save' }).or(page.getByRole('button', { name: 'Submit' }))Layout Selectors
// Relative to another element
page.getByText('Username').locator('xpath=following-sibling::input')
// Near another element (experimental)
page.locator('button').near(page.getByText('Total'))---
Selector Decision Tree
Use this flowchart to choose the right selector strategy:
Is the element interactive (button, link, input)?
├─ YES → Does it have visible text/label?
│ ├─ YES → Use getByRole() with name
│ │ Example: getByRole('button', { name: 'Submit' })
│ └─ NO → Does it have a label association?
│ ├─ YES → Use getByLabel()
│ └─ NO → Does it have placeholder text?
│ ├─ YES → Use getByPlaceholder()
│ └─ NO → Add data-testid, use getByTestId()
│
└─ NO → Is it a heading?
├─ YES → Use getByRole('heading', { name, level })
└─ NO → Does it have meaningful text?
├─ YES → Use getByText()
└─ NO → Is it an image?
├─ YES → Use getByAltText()
└─ NO → Does it have a title?
├─ YES → Use getByTitle()
└─ NO → Add data-testid, use getByTestId()
Last resort: use CSS locatorPriority Order
1. `getByRole` — Most resilient, mirrors accessibility tree 2. `getByLabel` — Great for form fields 3. `getByPlaceholder` — Fallback for unlabeled inputs 4. `getByText` — Good for non-interactive content 5. `getByAltText` — Images 6. `getByTitle` — Elements with title attribute 7. `getByTestId` — When semantic selectors aren't possible 8. CSS/XPath — Last resort only
---
Best Practices
Prefer Semantic Selectors
// GOOD — resilient to DOM changes
page.getByRole('button', { name: 'Add to cart' })
// BAD — breaks if class name changes
page.locator('.btn-primary.add-cart-btn')
// BAD — breaks if DOM structure changes
page.locator('div > div:nth-child(3) > button')Use Exact Matching When Appropriate
// Matches "Log in" but not "Log in with Google"
page.getByRole('button', { name: 'Log in', exact: true })
// Regex for flexible matching
page.getByRole('button', { name: /log\s*in/i })Scope Selectors to Avoid Ambiguity
// Scope to a section when multiple similar elements exist
const loginForm = page.locator('[data-testid="login-form"]')
await loginForm.getByLabel('Email').fill('user@example.com')
await loginForm.getByRole('button', { name: 'Sign in' }).click()Handle Dynamic Content
// Wait for element to appear
await page.getByRole('alert').waitFor()
// Wait for specific text
await page.getByText('Recipe saved!').waitFor({ state: 'visible' })
// Wait for element to disappear
await page.getByRole('progressbar').waitFor({ state: 'hidden' })Avoid Fragile Selectors
// AVOID: positional selectors
page.locator('table tr:nth-child(5) td:nth-child(2)')
// PREFER: content-based filtering
page.getByRole('row', { name: 'Pasta Recipe' }).getByRole('cell').nth(1)
// AVOID: auto-generated class names
page.locator('.css-1a2b3c4')
// PREFER: semantic or test-id selectors
page.getByTestId('recipe-title')Web Testing Patterns
Patterns and best practices for Playwright-based web testing.
---
Page Object Model (POM)
Encapsulate page interactions in reusable classes.
// pages/LoginPage.ts
import { type Page, type Locator } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = 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.page.goto('/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)
}
}// Usage in tests
import { LoginPage } from './pages/LoginPage'
test('successful login', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@test.com', 'password123')
await expect(page).toHaveURL('/dashboard')
})---
Fixtures and Test Setup
Custom Fixtures
// fixtures.ts
import { test as base } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
import { RecipePage } from './pages/RecipePage'
type Fixtures = {
loginPage: LoginPage
recipePage: RecipePage
}
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await use(loginPage)
},
recipePage: async ({ page }, use) => {
const recipePage = new RecipePage(page)
await use(recipePage)
},
})
export { expect } from '@playwright/test'Before/After Hooks
test.describe('Recipe Management', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/recipes')
await page.waitForLoadState('networkidle')
})
test.afterEach(async ({ page }) => {
// Cleanup: delete test data via API
await page.request.delete('/api/test/cleanup')
})
test('creates a recipe', async ({ page }) => {
// test implementation
})
})---
Authentication State Reuse
Avoid logging in for every test by saving and reusing auth state.
Global Setup
// global-setup.ts
import { chromium, type FullConfig } from '@playwright/test'
async function globalSetup(config: FullConfig) {
const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto('http://localhost:3000/login')
await page.getByLabel('Email').fill('admin@test.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('/dashboard')
await page.context().storageState({ path: '.auth/admin.json' })
await browser.close()
}
export default globalSetupUse in Config
// playwright.config.ts
export default defineConfig({
globalSetup: require.resolve('./global-setup'),
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'authenticated',
use: { storageState: '.auth/admin.json' },
dependencies: ['setup'],
},
{
name: 'unauthenticated',
testMatch: /.*\.unauth\.spec\.ts/,
// No storageState — fresh context
},
],
})---
API Mocking with Route Interception
Mock API Responses
test('displays recipes from API', async ({ page }) => {
await page.route('**/api/recipes', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, title: 'Mock Pasta', time: 30 },
{ id: 2, title: 'Mock Salad', time: 15 },
]),
})
})
await page.goto('/recipes')
await expect(page.getByText('Mock Pasta')).toBeVisible()
await expect(page.getByText('Mock Salad')).toBeVisible()
})Simulate Error States
test('shows error when API fails', async ({ page }) => {
await page.route('**/api/recipes', (route) =>
route.fulfill({ status: 500, body: 'Internal Server Error' })
)
await page.goto('/recipes')
await expect(page.getByText('Failed to load recipes')).toBeVisible()
})Modify Real Responses
test('augments real API data', async ({ page }) => {
await page.route('**/api/recipes', async (route) => {
const response = await route.fetch()
const json = await response.json()
json.push({ id: 999, title: 'Injected Recipe', time: 5 })
await route.fulfill({ response, json })
})
await page.goto('/recipes')
await expect(page.getByText('Injected Recipe')).toBeVisible()
})---
Visual Regression Testing
Screenshot Comparisons
test('home page visual', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('home-page.png', {
maxDiffPixelRatio: 0.01,
})
})
test('recipe card visual', async ({ page }) => {
await page.goto('/recipes')
const card = page.locator('.recipe-card').first()
await expect(card).toHaveScreenshot('recipe-card.png')
})Update Snapshots
# Generate or update baseline screenshots
npx playwright test --update-snapshots---
Accessibility Testing with axe-core
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('home page has no a11y violations', async ({ page }) => {
await page.goto('/')
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze()
expect(results.violations).toEqual([])
})
test('form has no a11y violations', async ({ page }) => {
await page.goto('/recipes/new')
const results = await new AxeBuilder({ page })
.include('form')
.exclude('.third-party-widget')
.analyze()
expect(results.violations).toEqual([])
})---
Mobile Viewport Testing
// In playwright.config.ts
import { devices } from '@playwright/test'
export default defineConfig({
projects: [
{ name: 'Desktop Chrome', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 14'] } },
{ name: 'Mobile Chrome', use: { ...devices['Pixel 7'] } },
{ name: 'Tablet', use: { ...devices['iPad Pro 11'] } },
],
})// In tests
test('mobile menu opens on hamburger click', async ({ page, isMobile }) => {
await page.goto('/')
if (isMobile) {
await page.getByRole('button', { name: 'Menu' }).click()
await expect(page.getByRole('navigation')).toBeVisible()
} else {
await expect(page.getByRole('navigation')).toBeVisible()
}
})---
Performance Assertions
test('page loads within performance budget', async ({ page }) => {
const startTime = Date.now()
await page.goto('/')
await page.waitForLoadState('networkidle')
const loadTime = Date.now() - startTime
expect(loadTime).toBeLessThan(3000) // 3s budget
})
test('no large layout shifts', async ({ page }) => {
await page.goto('/')
const cls = await page.evaluate(() => {
return new Promise<number>((resolve) => {
let clsValue = 0
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value
}
}
})
observer.observe({ type: 'layout-shift', buffered: true })
setTimeout(() => {
observer.disconnect()
resolve(clsValue)
}, 3000)
})
})
expect(cls).toBeLessThan(0.1)
})---
Retry Strategies
// playwright.config.ts
export default defineConfig({
retries: process.env.CI ? 2 : 0,
expect: {
timeout: 5000,
},
use: {
actionTimeout: 10000,
navigationTimeout: 30000,
},
})// Soft assertions — continue test after failure
test('multiple checks', async ({ page }) => {
await page.goto('/recipes')
await expect.soft(page.getByText('Pasta')).toBeVisible()
await expect.soft(page.getByText('Salad')).toBeVisible()
await expect.soft(page.getByText('Soup')).toBeVisible()
// Test reports all failures, not just the first
})---
Parallel Test Execution
// playwright.config.ts
export default defineConfig({
fullyParallel: true,
workers: process.env.CI ? 4 : undefined,
})// Serial execution when tests share state
test.describe.serial('Order flow', () => {
test('add item to cart', async ({ page }) => { /* ... */ })
test('proceed to checkout', async ({ page }) => { /* ... */ })
test('confirm payment', async ({ page }) => { /* ... */ })
})---
Test Data Management
// test-data/recipes.ts
export const testRecipes = {
pasta: {
title: 'Test Pasta Recipe',
description: 'A test recipe for E2E testing',
ingredients: ['pasta', 'sauce', 'cheese'],
time: 30,
},
salad: {
title: 'Test Salad Recipe',
description: 'Fresh test salad',
ingredients: ['lettuce', 'tomato', 'dressing'],
time: 10,
},
}
// Generate unique test data
export function uniqueRecipe(base = testRecipes.pasta) {
return {
...base,
title: `${base.title} ${Date.now()}`,
}
}// Seed via API before tests
test.beforeEach(async ({ request }) => {
await request.post('/api/test/seed', {
data: { recipes: [testRecipes.pasta, testRecipes.salad] },
})
})---
CI Integration Patterns
GitHub Actions
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
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 --with-deps
- run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30Sharding for Large Test Suites
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}Running Against Preview Deployments
jobs:
test:
steps:
- run: npx playwright test
env:
BASE_URL: ${{ github.event.deployment_status.target_url }}<#
.SYNOPSIS
Scaffolds Playwright test files with boilerplate code.
.DESCRIPTION
Generates Playwright test files including a Page Object class and test
cases skeleton based on the specified test type: e2e, visual, a11y, or
performance.
.PARAMETER Name
The test suite name (used for file and class naming). Required.
.PARAMETER Url
The base URL for the test suite. Defaults to "http://localhost:3000".
.PARAMETER Type
Test type: e2e, visual, a11y, or performance. Defaults to "e2e".
.EXAMPLE
.\test-scaffold.ps1 -Name "RecipeSearch" -Url "http://localhost:5173" -Type e2e
.EXAMPLE
.\test-scaffold.ps1 -Name "HomePage" -Type visual
.EXAMPLE
.\test-scaffold.ps1 -Name "LoginForm" -Type a11y
#>
param(
[Parameter(Mandatory = $true, Position = 0)]
[string]$Name,
[Parameter(Position = 1)]
[string]$Url = "http://localhost:3000",
[Parameter(Position = 2)]
[ValidateSet("e2e", "visual", "a11y", "performance")]
[string]$Type = "e2e"
)
$ErrorActionPreference = "Stop"
$kebabName = ($Name -creplace '([A-Z])', '-$1').Trim('-').ToLower()
$pascalName = $Name
$testsDir = "tests"
$pagesDir = "tests/pages"
if (-not (Test-Path $testsDir)) { New-Item -ItemType Directory -Path $testsDir -Force | Out-Null }
if (-not (Test-Path $pagesDir)) { New-Item -ItemType Directory -Path $pagesDir -Force | Out-Null }
Write-Host "Scaffolding $Type test: $Name" -ForegroundColor Cyan
Write-Host " Base URL: $Url" -ForegroundColor DarkGray
# --- Page Object ---
$pageObjectPath = "$pagesDir/${pascalName}Page.ts"
$pageObjectContent = @"
import { type Page, type Locator, expect } from '@playwright/test'
export class ${pascalName}Page {
readonly page: Page
// Define locators here
readonly heading: Locator
readonly mainContent: Locator
constructor(page: Page) {
this.page = page
this.heading = page.getByRole('heading', { level: 1 })
this.mainContent = page.locator('main')
}
async goto(path = '/') {
await this.page.goto(path)
await this.page.waitForLoadState('domcontentloaded')
}
async expectVisible() {
await expect(this.mainContent).toBeVisible()
}
}
"@
Set-Content -Path $pageObjectPath -Value $pageObjectContent
Write-Host " Created: $pageObjectPath" -ForegroundColor Green
# --- Test File ---
$testPath = "$testsDir/${kebabName}.spec.ts"
switch ($Type) {
"e2e" {
$testContent = @"
import { test, expect } from '@playwright/test'
import { ${pascalName}Page } from './pages/${pascalName}Page'
test.describe('${Name} E2E Tests', () => {
let ${Name.ToLower()}Page: ${pascalName}Page
test.beforeEach(async ({ page }) => {
${Name.ToLower()}Page = new ${pascalName}Page(page)
await ${Name.ToLower()}Page.goto()
})
test('should display the page correctly', async ({ page }) => {
await ${Name.ToLower()}Page.expectVisible()
await expect(${Name.ToLower()}Page.heading).toBeVisible()
})
test('should handle user interaction', async ({ page }) => {
// TODO: Implement interaction test
// Example:
// await page.getByRole('button', { name: 'Action' }).click()
// await expect(page.getByText('Result')).toBeVisible()
})
test('should navigate correctly', async ({ page }) => {
// TODO: Implement navigation test
// Example:
// await page.getByRole('link', { name: 'Details' }).click()
// await expect(page).toHaveURL(/\/details/)
})
test('should handle error states', async ({ page }) => {
// TODO: Mock API error and verify error UI
// await page.route('**/api/data', route =>
// route.fulfill({ status: 500 })
// )
// await page.reload()
// await expect(page.getByText('Something went wrong')).toBeVisible()
})
test('should handle empty states', async ({ page }) => {
// TODO: Mock empty response and verify empty state UI
// await page.route('**/api/data', route =>
// route.fulfill({ status: 200, body: JSON.stringify([]) })
// )
// await page.reload()
// await expect(page.getByText('No items found')).toBeVisible()
})
})
"@
}
"visual" {
$testContent = @"
import { test, expect } from '@playwright/test'
import { ${pascalName}Page } from './pages/${pascalName}Page'
test.describe('${Name} Visual Regression Tests', () => {
let ${Name.ToLower()}Page: ${pascalName}Page
test.beforeEach(async ({ page }) => {
${Name.ToLower()}Page = new ${pascalName}Page(page)
await ${Name.ToLower()}Page.goto()
await ${Name.ToLower()}Page.expectVisible()
})
test('full page screenshot', async ({ page }) => {
await expect(page).toHaveScreenshot('${kebabName}-full.png', {
fullPage: true,
maxDiffPixelRatio: 0.01,
})
})
test('main content screenshot', async ({ page }) => {
await expect(${Name.ToLower()}Page.mainContent).toHaveScreenshot(
'${kebabName}-main.png'
)
})
test('dark mode screenshot', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' })
await expect(page).toHaveScreenshot('${kebabName}-dark.png', {
fullPage: true,
})
})
test('mobile viewport screenshot', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 })
await expect(page).toHaveScreenshot('${kebabName}-mobile.png', {
fullPage: true,
})
})
test('tablet viewport screenshot', async ({ page }) => {
await page.setViewportSize({ width: 768, height: 1024 })
await expect(page).toHaveScreenshot('${kebabName}-tablet.png', {
fullPage: true,
})
})
})
"@
}
"a11y" {
$testContent = @"
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
import { ${pascalName}Page } from './pages/${pascalName}Page'
test.describe('${Name} Accessibility Tests', () => {
let ${Name.ToLower()}Page: ${pascalName}Page
test.beforeEach(async ({ page }) => {
${Name.ToLower()}Page = new ${pascalName}Page(page)
await ${Name.ToLower()}Page.goto()
await ${Name.ToLower()}Page.expectVisible()
})
test('should have no WCAG 2.0 A violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a'])
.analyze()
expect(results.violations).toEqual([])
})
test('should have no WCAG 2.0 AA violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2aa'])
.analyze()
expect(results.violations).toEqual([])
})
test('should have proper heading hierarchy', async ({ page }) => {
const headings = await page.evaluate(() => {
const hs = document.querySelectorAll('h1, h2, h3, h4, h5, h6')
return Array.from(hs).map((h) => ({
level: parseInt(h.tagName[1]),
text: h.textContent?.trim(),
}))
})
// Verify h1 exists and heading levels don't skip
expect(headings.length).toBeGreaterThan(0)
expect(headings[0].level).toBe(1)
})
test('should have proper keyboard navigation', async ({ page }) => {
// Tab through interactive elements
await page.keyboard.press('Tab')
const firstFocused = await page.evaluate(() => document.activeElement?.tagName)
expect(firstFocused).toBeTruthy()
// Verify focus is visible
const focusedElement = page.locator(':focus')
await expect(focusedElement).toBeVisible()
})
test('should have sufficient color contrast', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze()
expect(results.violations).toEqual([])
})
test('images should have alt text', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withRules(['image-alt'])
.analyze()
expect(results.violations).toEqual([])
})
})
"@
}
"performance" {
$testContent = @"
import { test, expect } from '@playwright/test'
import { ${pascalName}Page } from './pages/${pascalName}Page'
test.describe('${Name} Performance Tests', () => {
let ${Name.ToLower()}Page: ${pascalName}Page
test.beforeEach(async ({ page }) => {
${Name.ToLower()}Page = new ${pascalName}Page(page)
})
test('page loads within budget (3s)', async ({ page }) => {
const start = Date.now()
await ${Name.ToLower()}Page.goto()
await page.waitForLoadState('networkidle')
const loadTime = Date.now() - start
console.log('Load time: ' + loadTime + 'ms')
expect(loadTime).toBeLessThan(3000)
})
test('First Contentful Paint within budget', async ({ page }) => {
await ${Name.ToLower()}Page.goto()
const fcp = await page.evaluate(() => {
const entry = performance.getEntriesByName('first-contentful-paint')[0]
return entry ? entry.startTime : null
})
expect(fcp).not.toBeNull()
expect(fcp).toBeLessThan(1500)
})
test('no excessive DOM nodes', async ({ page }) => {
await ${Name.ToLower()}Page.goto()
const nodeCount = await page.evaluate(
() => document.querySelectorAll('*').length
)
console.log('DOM nodes: ' + nodeCount)
expect(nodeCount).toBeLessThan(1500)
})
test('Cumulative Layout Shift within budget', async ({ page }) => {
await ${Name.ToLower()}Page.goto()
const cls = await page.evaluate(() => {
return new Promise<number>((resolve) => {
let clsValue = 0
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!(entry as any).hadRecentInput) {
clsValue += (entry as any).value
}
}
})
observer.observe({ type: 'layout-shift', buffered: true })
setTimeout(() => { observer.disconnect(); resolve(clsValue) }, 3000)
})
})
console.log('CLS: ' + cls)
expect(cls).toBeLessThan(0.1)
})
test('no large network payloads', async ({ page }) => {
const responses: { url: string; size: number }[] = []
page.on('response', async (response) => {
const size = (await response.body().catch(() => Buffer.from(''))).length
if (size > 500_000) {
responses.push({ url: response.url(), size })
}
})
await ${Name.ToLower()}Page.goto()
await page.waitForLoadState('networkidle')
if (responses.length > 0) {
console.log('Large payloads:', responses)
}
expect(responses).toHaveLength(0)
})
})
"@
}
}
Set-Content -Path $testPath -Value $testContent
Write-Host " Created: $testPath" -ForegroundColor Green
# --- Config scaffold (only if it doesn't exist) ---
$configPath = "playwright.config.ts"
if (-not (Test-Path $configPath)) {
$configContent = @"
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 ? 4 : undefined,
reporter: [
['html'],
['list'],
],
use: {
baseURL: '${Url}',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 7'] },
},
],
webServer: {
command: 'npm run dev',
url: '${Url}',
reuseExistingServer: !process.env.CI,
},
})
"@
Set-Content -Path $configPath -Value $configContent
Write-Host " Created: $configPath" -ForegroundColor Green
}
Write-Host "`nScaffolding complete!" -ForegroundColor Green
Write-Host "Files created:"
Write-Host " - $pageObjectPath"
Write-Host " - $testPath"
if (-not (Test-Path "$configPath.bak")) { Write-Host " - $configPath (if new)" }
Write-Host "`nNext steps:"
Write-Host " 1. npm init playwright@latest (if not already installed)"
if ($Type -eq "a11y") { Write-Host " 2. npm install -D @axe-core/playwright" }
Write-Host " 2. Update page object locators for your actual UI"
Write-Host " 3. npx playwright test ${kebabName}.spec.ts"
Related skills
FAQ
What does web-testing do?
web-testing is a Claude Code skill for testing & qa.
When should I use web-testing?
When you need to helps with testing & qa tasks., or when web-testing is a claude code skill for testing & qa.
What are the main capabilities?
web-testing; Testing & QA; AI-coding skill.