playwright-writing
Playwright Writing
Purpose
Guide the creation of reliable, maintainable Playwright E2E tests that test real user-visible behavior against real application data.
When NOT to Use
- Unit tests (use Jest/Vitest instead)
- Integration tests that don't need browser automation
- API-only testing (use Playwright's API testing or dedicated tools)
- Performance/load testing (use k6, Artillery, etc.)
🚫 FORBIDDEN Patterns (Zero Tolerance)
Never Mock Application Data
Your tests MUST hit your real API endpoints.
// ❌ FORBIDDEN - mocking YOUR app's API
await page.route('/api/users', route => route.fulfill({
body: JSON.stringify([{ id: 1, name: 'Mock User' }])
}));
await page.route('/api/products/**', route => route.fulfill({
body: JSON.stringify({ price: 99.99 })
}));
Exception: External third-party APIs you don't control:
// ✅ ACCEPTABLE - mocking external third-party
await page.route('https://api.stripe.com/**', route => route.fulfill({
body: JSON.stringify({ success: true })
}));
await page.route('https://analytics.google.com/**', route => route.abort());
Never Use Explicit Timeouts
page.waitForTimeout() is FORBIDDEN.
// ❌ FORBIDDEN - arbitrary wait
await page.waitForTimeout(2000);
await page.waitForTimeout(500);
// ❌ FORBIDDEN - sleep/delay patterns
await new Promise(resolve => setTimeout(resolve, 1000));
Use web-first assertions that auto-wait instead:
// ✅ CORRECT - waits for condition automatically
await expect(page.getByText('Loaded')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
Never Use CSS Class Selectors
CSS classes are for styling, not testing.
// ❌ FORBIDDEN - CSS class selectors
page.locator('.btn-primary')
page.locator('.submit-form')
page.locator('.MuiButton-root')
page.locator('.card-container > .item')
// ❌ FORBIDDEN - complex CSS selectors
page.locator('div.sidebar ul.menu li.active a')
Never Use Manual Assertions Without Await
Always use web-first assertions.
// ❌ FORBIDDEN - manual assertion
expect(await page.locator('#status').isVisible()).toBe(true);
expect(await page.getByText('Hello').textContent()).toBe('Hello');
// ✅ CORRECT - web-first assertion
await expect(page.getByTestId('status')).toBeVisible();
await expect(page.getByText('Hello')).toHaveText('Hello');
✅ REQUIRED Patterns
Web-First Assertions
Web-first assertions auto-wait and auto-retry until the condition is met.
// ✅ These wait and retry automatically
await expect(page).toHaveTitle(/Dashboard/);
await expect(page.getByRole('heading')).toHaveText('Welcome');
await expect(page.getByTestId('submit')).toBeEnabled();
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('listitem')).toHaveCount(3);
Common web-first matchers:
toBeVisible()- element is visibletoBeEnabled()/toBeDisabled()- element statetoHaveText()- exact or partial text matchtoHaveValue()- input valuetoHaveAttribute()- attribute checktoBeChecked()- checkbox/radio statetoHaveCount()- number of elements
User-Facing Locators
Locators should reflect how users find elements.
// ✅ REQUIRED - user-facing locators
page.getByRole('button', { name: 'Submit' })
page.getByRole('link', { name: 'Sign up' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('heading', { level: 1 })
page.getByLabel('Password')
page.getByPlaceholder('Search...')
page.getByText('Welcome back')
page.getByTestId('user-profile') // fallback when needed
Test Isolation
Each test gets a fresh browser context. Use beforeEach for setup.
import { test, expect } from '@playwright/test';
test.describe('User Dashboard', () => {
test.beforeEach(async ({ page }) => {
// Navigate to starting point
await page.goto('/dashboard');
// Login if needed
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('password123');
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for dashboard to load
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('shows user profile', async ({ page }) => {
await page.getByRole('link', { name: 'Profile' }).click();
await expect(page.getByText('Profile Settings')).toBeVisible();
});
test('displays notifications', async ({ page }) => {
await page.getByRole('button', { name: 'Notifications' }).click();
await expect(page.getByRole('list')).toBeVisible();
});
});
Locator Priority Hierarchy
Use locators in this order of preference:
| Priority | Locator | When to Use |
|---|---|---|
| 1 | getByRole() |
Buttons, links, headings, inputs - accessibility semantics |
| 2 | getByText() |
Unique visible text content |
| 3 | getByLabel() |
Form fields with labels |
| 4 | getByPlaceholder() |
Inputs with placeholder text |
| 5 | getByTestId() |
Complex components, disambiguation needed |
| ❌ | .locator('.class') |
NEVER use CSS classes |
Chaining and Filtering
For complex scenarios, chain and filter locators:
// Filter by text within a container
const product = page.getByRole('listitem').filter({ hasText: 'Product 2' });
await product.getByRole('button', { name: 'Add to cart' }).click();
// Filter by containing element
await page
.getByRole('listitem')
.filter({ has: page.getByRole('heading', { name: 'Premium' }) })
.getByRole('button', { name: 'Buy' })
.click();
When Waits ARE Acceptable
Waiting for Network Requests
// ✅ Wait for specific API response
await page.waitForResponse('/api/data');
await page.waitForResponse(response =>
response.url().includes('/api/users') && response.status() === 200
);
// ✅ Wait for navigation to complete
await page.waitForURL('**/dashboard');
Waiting for Page Load States
// ✅ Wait for network idle (all requests complete)
await page.waitForLoadState('networkidle');
// ✅ Wait for DOM content loaded
await page.waitForLoadState('domcontentloaded');
Waiting for Specific Elements (via assertions)
// ✅ These are web-first assertions that auto-wait
await expect(page.getByText('Loading...')).toBeHidden();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByRole('progressbar')).toBeHidden();
Mantine Component Patterns
Mantine UI components are NOT native HTML elements. They require special handling.
Mantine Select
Mantine Select is a combination of <input> and <div> elements. selectOption() does NOT work.
// ❌ DOES NOT WORK with Mantine Select
await page.getByRole('combobox').selectOption('value');
await page.locator('select').selectOption('option');
// ✅ CORRECT pattern for Mantine Select
await page.locator('[data-testid="SetStatusSelect"]').click(); // Open dropdown
await page.locator('div[value="archivePending"]').click(); // Select option
await page.locator('[data-testid="SetStatusButton"]').click(); // Submit if needed
Mantine Menu
// ✅ CORRECT pattern for Mantine Menu
await page.locator('[data-testid="UserMenu"]').click(); // Open menu
await page.getByRole('menuitem', { name: 'Settings' }).click(); // Click item
When to Use data-testid
Use data-testid when:
- Multiple similar components exist on a page
- Role-based locators cannot uniquely identify the element
- Component structure makes semantic locators unreliable
// When you have multiple "Submit" buttons
await page.locator('[data-testid="payment-submit"]').click();
await page.locator('[data-testid="shipping-submit"]').click();
NEVER Skip Tests (Zero Tolerance)
If a test fails, FIX IT. Never skip it.
// ❌ FORBIDDEN - skipping tests
test.skip('user can checkout', async ({ page }) => { ... });
// ❌ FORBIDDEN - commenting out tests
// test('user can checkout', async ({ page }) => { ... });
// ❌ FORBIDDEN - conditional skipping to hide failures
test('user can checkout', async ({ page }) => {
test.skip(true, 'TODO: fix later'); // ❌ NEVER DO THIS
});
When a Test Fails
- Investigate the failure - Is it a code bug or test bug?
- Fix the root cause - Either in application code or test code
- Re-run to verify - Test must pass consistently
- Never use
.skip()as a solution - Skipping hides bugs
Temporary Skip ONLY For
These are the ONLY acceptable temporary skip reasons:
- Feature is intentionally disabled in this environment
- External dependency is known to be down (with ticket to re-enable)
- Test requires infrastructure not yet available
Even then, add a follow-up ticket and timeline.
Test Structure Template
import { test, expect } from '@playwright/test';
test.describe('Feature: User Authentication', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
});
test('successful login redirects to dashboard', async ({ page }) => {
// Arrange - already done in beforeEach
// Act
await page.getByLabel('Email').fill('valid@example.com');
await page.getByLabel('Password').fill('correctpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Assert
await expect(page).toHaveURL(/.*dashboard/);
await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});
test('invalid credentials shows error message', async ({ page }) => {
// Act
await page.getByLabel('Email').fill('user@example.com');
await page.getByLabel('Password').fill('wrongpassword');
await page.getByRole('button', { name: 'Sign in' }).click();
// Assert
await expect(page.getByRole('alert')).toHaveText('Invalid credentials');
await expect(page).toHaveURL('/login'); // Stays on login page
});
});
Codegen Usage
Generate tests and locators using Playwright's codegen tool.
# Generate test by recording interactions
npx playwright codegen https://your-app.com
# Generate with specific viewport
npx playwright codegen --viewport-size=1280,720 https://your-app.com
# Generate for mobile device
npx playwright codegen --device="iPhone 13" https://your-app.com
After generating:
- Review generated locators - upgrade CSS selectors to user-facing locators
- Add proper assertions - codegen focuses on actions
- Add test isolation - wrap in
test.describewithbeforeEach - Remove any
waitForTimeoutcalls
Debugging & Running Tests
Running Tests
# Run all tests
npx playwright test
# Run specific file
npx playwright test auth.spec.ts
# Run tests matching name
npx playwright test -g "login"
# Run in headed mode (see browser)
npx playwright test --headed
# Run specific browser
npx playwright test --project=chromium
Debugging
# UI Mode - visual debugger (RECOMMENDED)
npx playwright test --ui
# Debug mode with inspector
npx playwright test --debug
# Debug specific test from line number
npx playwright test auth.spec.ts:25 --debug
Viewing Reports
# Show HTML report
npx playwright show-report
# Generate trace for CI debugging
npx playwright test --trace on
Best Practices Checklist
Before committing Playwright tests, verify:
Locators
- Using user-facing locators (
getByRole,getByText,getByLabel) - NO CSS class selectors
-
data-testidonly when semantic locators insufficient - Locators are resilient to minor UI changes
Assertions
- All assertions use web-first matchers with
await expect() - No manual
isVisible()/textContent()checks - Assertions verify user-visible behavior
No Forbidden Patterns
- NO
page.waitForTimeout()calls - NO mocking of application APIs
- NO skipped tests (
.skip()) - NO commented-out tests
Test Quality
- Each test is independent (no shared state)
-
beforeEachhandles common setup - Tests verify real application behavior
- Mantine components use correct click patterns
Before Merge
- All tests pass locally:
npx playwright test - Tests pass on all target browsers
- No flaky tests (run 3x to verify)
Related Agent
For comprehensive E2E testing guidance that coordinates this and other Playwright skills, use the playwright-e2e-expert agent.
More from meriley/claude-code-skills
obs-cpp-qt-patterns
C++ and Qt integration patterns for OBS Studio plugins. Covers Qt6 Widgets for settings dialogs, CMAKE_AUTOMOC, OBS frontend API, optional Qt builds with C fallbacks, and modal dialog patterns. Use when adding UI components or C++ features to OBS plugins.
56vendure-developing
Develop Vendure e-commerce plugins, extend GraphQL APIs, create Admin UI components, and define database entities. Use vendure-expert agent for comprehensive guidance across all Vendure development domains.
36vendure-admin-ui-writing
Create Vendure Admin UI extensions with React components, route registration, navigation menus, and GraphQL integration. Handles useQuery, useMutation, useInjector patterns. Use when building Admin UI features for Vendure plugins.
33vendure-entity-writing
Define Vendure database entities extending VendureEntity, with TypeORM decorators, relations, custom fields, and channel-awareness. Use when creating database models in Vendure.
31vendure-graphql-writing
Extend Vendure GraphQL schema with custom types, queries, mutations, and resolvers. Handles RequestContext threading, permissions, and dual Shop/Admin API separation. Use when adding GraphQL endpoints to Vendure.
31vendure-plugin-writing
Create production-ready Vendure plugins with @VendurePlugin decorator, NestJS dependency injection, lifecycle hooks, and configuration patterns. Use when developing new Vendure plugins or extending existing ones.
29