Initial commit
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
# Playwright Best Practices
|
||||
|
||||
Official best practices for Playwright test automation, optimized for LLM-assisted development.
|
||||
|
||||
## Test Structure
|
||||
|
||||
### Use Page Object Models (POM)
|
||||
|
||||
**Why**: Separates page structure from test logic, improves maintainability
|
||||
|
||||
```typescript
|
||||
// Good: Page Object Model
|
||||
// pages/login.page.ts
|
||||
export class LoginPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto('/login');
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
await this.page.getByLabel('Username').fill(username);
|
||||
await this.page.getByLabel('Password').fill(password);
|
||||
await this.page.getByRole('button', { name: 'Sign in' }).click();
|
||||
}
|
||||
}
|
||||
|
||||
// specs/login.spec.ts
|
||||
test('user can login', async ({ page }) => {
|
||||
const loginPage = new LoginPage(page);
|
||||
await loginPage.goto();
|
||||
await loginPage.login('user@example.com', 'password123');
|
||||
await expect(page).toHaveURL('/dashboard');
|
||||
});
|
||||
```
|
||||
|
||||
### Use Semantic Selectors
|
||||
|
||||
**Priority order** (most stable → least stable):
|
||||
|
||||
1. **getByRole** - Accessible role (button, heading, textbox, etc.)
|
||||
2. **getByLabel** - Form inputs with associated labels
|
||||
3. **getByPlaceholder** - Input placeholder text
|
||||
4. **getByText** - User-visible text content
|
||||
5. **getByTestId** - data-testid attributes (last resort)
|
||||
|
||||
```typescript
|
||||
// Best: Role-based (accessible and stable)
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Good: Label-based (for forms)
|
||||
await page.getByLabel('Email address').fill('user@example.com');
|
||||
|
||||
// Acceptable: Text-based
|
||||
await page.getByText('Continue to checkout').click();
|
||||
|
||||
// Avoid: CSS selectors (brittle)
|
||||
await page.click('.btn-primary'); // ❌ Breaks if class changes
|
||||
|
||||
// Last resort: Test IDs (when semantic selectors don't work)
|
||||
await page.getByTestId('checkout-button').click();
|
||||
```
|
||||
|
||||
## Screenshot Best Practices
|
||||
|
||||
### When to Capture Screenshots
|
||||
|
||||
1. **Initial page load** - Baseline visual state
|
||||
2. **Before interaction** - Pre-state for comparison
|
||||
3. **After interaction** - Result of user action
|
||||
4. **Error states** - When validation fails or errors occur
|
||||
5. **Success states** - Confirmation screens, success messages
|
||||
6. **Test failures** - Automatic capture for debugging
|
||||
|
||||
### Screenshot Naming Convention
|
||||
|
||||
```typescript
|
||||
// Pattern: {test-name}-{viewport}-{state}-{timestamp}.png
|
||||
|
||||
await page.screenshot({
|
||||
path: `screenshots/current/login-desktop-initial-${Date.now()}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: `screenshots/current/checkout-mobile-error-${Date.now()}.png`,
|
||||
fullPage: true
|
||||
});
|
||||
```
|
||||
|
||||
### Full-Page vs Element Screenshots
|
||||
|
||||
```typescript
|
||||
// Full-page: For layout and overall UI analysis
|
||||
await page.screenshot({
|
||||
path: 'homepage-full.png',
|
||||
fullPage: true // Captures entire scrollable page
|
||||
});
|
||||
|
||||
// Element-specific: For component testing
|
||||
const button = page.getByRole('button', { name: 'Submit' });
|
||||
await button.screenshot({
|
||||
path: 'submit-button.png'
|
||||
});
|
||||
```
|
||||
|
||||
## Waiting and Timing
|
||||
|
||||
### Auto-Waiting
|
||||
|
||||
Playwright automatically waits for:
|
||||
- Element to be attached to DOM
|
||||
- Element to be visible
|
||||
- Element to be stable (not animating)
|
||||
- Element to receive events (not obscured)
|
||||
- Element to be enabled
|
||||
|
||||
```typescript
|
||||
// This automatically waits for button to be clickable
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
```
|
||||
|
||||
### Explicit Waits (when needed)
|
||||
|
||||
```typescript
|
||||
// Wait for navigation
|
||||
await page.waitForURL('/dashboard');
|
||||
|
||||
// Wait for network idle (good before screenshots)
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Wait for specific element
|
||||
await page.waitForSelector('img[alt="Profile picture"]');
|
||||
|
||||
// Wait for custom condition
|
||||
await page.waitForFunction(() => window.scrollY === 0);
|
||||
```
|
||||
|
||||
### Avoid Fixed Timeouts
|
||||
|
||||
```typescript
|
||||
// Bad: Arbitrary delays
|
||||
await page.waitForTimeout(3000); // ❌ Flaky, slow
|
||||
|
||||
// Good: Wait for specific condition
|
||||
await expect(page.getByText('Success')).toBeVisible(); // ✅ Fast and reliable
|
||||
```
|
||||
|
||||
## Test Isolation
|
||||
|
||||
### Independent Tests
|
||||
|
||||
Each test should be completely independent:
|
||||
|
||||
```typescript
|
||||
// Good: Test is self-contained
|
||||
test('user can add item to cart', async ({ page }) => {
|
||||
// Set up: Create user, log in
|
||||
await page.goto('/');
|
||||
await login(page, 'user@example.com', 'password');
|
||||
|
||||
// Action: Add to cart
|
||||
await page.getByRole('button', { name: 'Add to cart' }).click();
|
||||
|
||||
// Assert: Item in cart
|
||||
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
||||
|
||||
// Cleanup happens automatically with new page context
|
||||
});
|
||||
|
||||
// Bad: Depends on previous test state
|
||||
test('user can checkout', async ({ page }) => {
|
||||
// ❌ Assumes cart already has items from previous test
|
||||
await page.goto('/checkout');
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
### Use test.beforeEach for Common Setup
|
||||
|
||||
```typescript
|
||||
test.describe('Shopping cart', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await login(page, 'user@example.com', 'password');
|
||||
});
|
||||
|
||||
test('can add item to cart', async ({ page }) => {
|
||||
// Setup already done
|
||||
await page.getByRole('button', { name: 'Add to cart' }).click();
|
||||
await expect(page.getByTestId('cart-count')).toHaveText('1');
|
||||
});
|
||||
|
||||
test('can remove item from cart', async ({ page }) => {
|
||||
// Setup already done, fresh state
|
||||
await page.getByRole('button', { name: 'Add to cart' }).click();
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
await expect(page.getByTestId('cart-count')).toHaveText('0');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## Visual Regression Testing
|
||||
|
||||
### Snapshot Testing
|
||||
|
||||
```typescript
|
||||
// Basic snapshot
|
||||
await expect(page).toHaveScreenshot('homepage.png');
|
||||
|
||||
// With threshold (allow minor differences)
|
||||
await expect(page).toHaveScreenshot('homepage.png', {
|
||||
maxDiffPixelRatio: 0.05 // Allow 5% difference
|
||||
});
|
||||
|
||||
// Element snapshot
|
||||
const card = page.getByRole('article').first();
|
||||
await expect(card).toHaveScreenshot('product-card.png');
|
||||
```
|
||||
|
||||
### Updating Baselines
|
||||
|
||||
```bash
|
||||
# Update all snapshots
|
||||
npx playwright test --update-snapshots
|
||||
|
||||
# Update specific test
|
||||
npx playwright test login.spec.ts --update-snapshots
|
||||
```
|
||||
|
||||
### Baseline Management
|
||||
|
||||
- **Store baselines in git** - Commit to repository for consistency
|
||||
- **Review diffs carefully** - Not all changes are bugs
|
||||
- **Update deliberately** - Only update when changes are intentional
|
||||
- **Use CI checks** - Fail pipeline on unexpected visual changes
|
||||
|
||||
## Configuration Best Practices
|
||||
|
||||
### playwright.config.ts Essentials
|
||||
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
|
||||
// Timeout for each test
|
||||
timeout: 30 * 1000,
|
||||
|
||||
// Global setup/teardown
|
||||
globalSetup: require.resolve('./tests/setup/global-setup.ts'),
|
||||
|
||||
// Fail fast on CI, retry locally
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
|
||||
// Parallel execution
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
|
||||
// Reporter
|
||||
reporter: process.env.CI ? 'github' : 'html',
|
||||
|
||||
use: {
|
||||
// Base URL
|
||||
baseURL: 'http://localhost:5173',
|
||||
|
||||
// Screenshot on failure
|
||||
screenshot: 'only-on-failure',
|
||||
|
||||
// Trace on first retry
|
||||
trace: 'on-first-retry',
|
||||
|
||||
// Video on failure
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
|
||||
// Projects for multi-browser testing
|
||||
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 13'] },
|
||||
},
|
||||
],
|
||||
|
||||
// Web server for dev
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### Playwright Inspector
|
||||
|
||||
```bash
|
||||
# Debug specific test
|
||||
npx playwright test --debug login.spec.ts
|
||||
|
||||
# Debug from specific line
|
||||
npx playwright test --debug --grep "user can login"
|
||||
```
|
||||
|
||||
### VS Code Debugger
|
||||
|
||||
```json
|
||||
// .vscode/launch.json
|
||||
{
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Playwright Tests",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/node_modules/@playwright/test/cli.js",
|
||||
"args": ["test", "--headed", "${file}"],
|
||||
"console": "integratedTerminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Trace Viewer
|
||||
|
||||
```bash
|
||||
# Run with trace
|
||||
npx playwright test --trace on
|
||||
|
||||
# View trace
|
||||
npx playwright show-trace trace.zip
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
```typescript
|
||||
// Run tests in parallel (default)
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// Run tests serially (when needed)
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
```
|
||||
|
||||
### Reuse Authentication State
|
||||
|
||||
```typescript
|
||||
// global-setup.ts
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
export default async function globalSetup() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
|
||||
await page.goto('http://localhost:5173/login');
|
||||
await page.getByLabel('Username').fill('admin');
|
||||
await page.getByLabel('Password').fill('password');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
|
||||
// Save authentication state
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
// Use in tests
|
||||
test.use({ storageState: 'auth.json' });
|
||||
```
|
||||
|
||||
## Common Pitfalls to Avoid
|
||||
|
||||
### 1. Not Waiting for Network Idle Before Screenshots
|
||||
|
||||
```typescript
|
||||
// Bad: Screenshot may capture loading state
|
||||
await page.goto('/dashboard');
|
||||
await page.screenshot({ path: 'dashboard.png' });
|
||||
|
||||
// Good: Wait for content to load
|
||||
await page.goto('/dashboard');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.screenshot({ path: 'dashboard.png' });
|
||||
```
|
||||
|
||||
### 2. Using Non-Stable Selectors
|
||||
|
||||
```typescript
|
||||
// Bad: Position-based (breaks if order changes)
|
||||
await page.locator('button').nth(2).click();
|
||||
|
||||
// Good: Content-based
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
```
|
||||
|
||||
### 3. Not Handling Dynamic Content
|
||||
|
||||
```typescript
|
||||
// Bad: Assumes content is already loaded
|
||||
const text = await page.getByTestId('user-name').textContent();
|
||||
|
||||
// Good: Wait for element first
|
||||
await expect(page.getByTestId('user-name')).toBeVisible();
|
||||
const text = await page.getByTestId('user-name').textContent();
|
||||
```
|
||||
|
||||
### 4. Overly Broad Assertions
|
||||
|
||||
```typescript
|
||||
// Bad: Fails on any minor change
|
||||
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0 });
|
||||
|
||||
// Good: Allow reasonable tolerance
|
||||
await expect(page).toHaveScreenshot({ maxDiffPixelRatio: 0.02 });
|
||||
```
|
||||
|
||||
## Summary Checklist
|
||||
|
||||
- [ ] Use Page Object Models for test organization
|
||||
- [ ] Prefer semantic selectors (getByRole, getByLabel)
|
||||
- [ ] Capture screenshots at key interaction points
|
||||
- [ ] Wait for network idle before screenshots
|
||||
- [ ] Use auto-waiting instead of fixed timeouts
|
||||
- [ ] Make tests independent and isolated
|
||||
- [ ] Configure proper retry logic (2-3 retries in CI)
|
||||
- [ ] Store authentication state for reuse
|
||||
- [ ] Use trace viewer for debugging
|
||||
- [ ] Review visual diffs before updating baselines
|
||||
- [ ] Run tests in parallel for performance
|
||||
- [ ] Enable screenshot/video on failure
|
||||
- [ ] Store baselines in version control
|
||||
- [ ] Use meaningful screenshot names with timestamps
|
||||
- [ ] Configure appropriate visual diff thresholds
|
||||
|
||||
---
|
||||
|
||||
**References:**
|
||||
- [Playwright Official Docs](https://playwright.dev/)
|
||||
- [Best Practices Guide](https://playwright.dev/docs/best-practices)
|
||||
- [Locators Guide](https://playwright.dev/docs/locators)
|
||||
Reference in New Issue
Block a user