End-to-End Testing with Playwright: Complete Guide with Page Object Model
Master production-ready E2E testing with Playwright, Page Object Model, multi-browser testing, and CI/CD integration.
Table of Contents
- Why Playwright?
- Project Setup
- Page Object Model Pattern
- Complete E-Commerce Example
- Multi-Browser Testing
- Parallel Execution
- Retry & Flakiness Management
- Advanced Reporting with Allure
- Common Mistakes & Solutions
- CI/CD Integration (GitHub Actions)
- Best Practices
- Running Tests
Why Playwright?
Playwright has become the industry standard for E2E testing. Here's why:
✅ Cross-Browser Testing Out of the Box
Test on Chrome, Firefox, Safari, and Edge with the same API. No more managing separate drivers.
✅ Fast & Reliable
- Auto-waiting: Elements automatically wait to be ready
- Parallel execution: Run hundreds of tests simultaneously
- Low overhead: Tests run in seconds
- Minimal flakiness: Built-in resilience reduces false failures
✅ First-Class Debugging
- Playwright Inspector: Step-by-step debugging
- Trace Viewer: Record and replay execution
- Screenshots & Videos: Automatic captures on failure
- Locator Playground: Find elements interactively
✅ Production-Ready Features
- Mobile & device testing
- Network control & interception
- Multiple language support
- Strong community & Microsoft backing
Project Setup
Step 1: Initialize Project
mkdir playwright-ecommerce-tests
cd playwright-ecommerce-tests
npm init -y
Step 2: Install Dependencies
npm install -D @playwright/test
npm install -D @playwright/test-docker # Optional: for containerized testing
npm install dotenv # Environment variables
npx playwright install # Install browsers
Step 3: Install Allure Reporter
npm install -D allure-playwright
npm install -D @playwright/test
npm install -g allure-commandline
Step 4: Project Structure
playwright-ecommerce-tests/
├── tests/
│ ├── fixtures/
│ │ ├── base.ts # Base page class
│ │ └── auth.ts # Auth fixture
│ ├── pages/
│ │ ├── login-page.ts
│ │ ├── product-search-page.ts
│ │ ├── product-details-page.ts
│ │ ├── shopping-cart-page.ts
│ │ ├── checkout-page.ts
│ │ └── order-confirmation-page.ts
│ └── e2e/
│ ├── ecommerce-flow.test.ts
│ ├── auth.test.ts
│ └── cart.test.ts
├── playwright.config.ts
├── allure-results/ # Auto-generated
├── allure-report/ # Auto-generated
├── .env.example
├── .env
├── .github/
│ └── workflows/
│ └── playwright.yml
└── package.json
Step 5: Configuration Files
playwright.config.ts:
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests/e2e',
testMatch: '**/*.test.ts',
// ✅ Timeouts
timeout: process.env.CI ? 30000 : 10000,
expect: { timeout: 5000 },
// ✅ Parallel & Retry Configuration
fullyParallel: true,
workers: process.env.CI ? 4 : undefined, // 4 workers in CI, auto in local
retries: process.env.CI ? 2 : 0, // Retry failed tests in CI only
forbidOnly: !!process.env.CI,
// ✅ Reporting
reporter: [
['html', { outputFolder: 'playwright-report' }],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }],
['list'], // Console output
['allure-playwright'], // Allure reporting
],
// ✅ Global settings for all projects
use: {
baseURL: process.env.BASE_URL || 'https://demo.ecommerce.com',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
// ✅ Browser Projects with retry override
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
retries: process.env.CI ? 2 : 0,
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
retries: process.env.CI ? 3 : 0, // More retries for Firefox
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
retries: process.env.CI ? 3 : 0, // More retries for Safari
},
{
name: 'mobile-chrome',
use: { ...devices['Pixel 5'] },
retries: process.env.CI ? 2 : 0,
},
{
name: 'mobile-safari',
use: { ...devices['iPhone 12'] },
retries: process.env.CI ? 2 : 0,
},
],
// ✅ Web server configuration
webServer: {
command: 'npm run start',
url: process.env.BASE_URL || 'https://demo.ecommerce.com',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000, // 2 minutes to start server
},
});
.env.example:
BASE_URL=https://demo.ecommerce.com
TEST_USER_EMAIL=testuser@example.com
TEST_USER_PASSWORD=SecurePassword123!
TEST_PRODUCT_NAME=Laptop
TIMEOUT=30000
ALLURE_RESULTS_DIR=./allure-results
package.json:
{
"name": "playwright-ecommerce-tests",
"version": "1.0.0",
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:ui": "playwright test --ui",
"test:chrome": "playwright test --project=chromium",
"test:firefox": "playwright test --project=firefox",
"test:webkit": "playwright test --project=webkit",
"test:mobile": "playwright test --project=mobile-chrome",
"test:serial": "playwright test --workers=1",
"test:parallel-2": "playwright test --workers=2",
"test:parallel-4": "playwright test --workers=4",
"test:parallel-8": "playwright test --workers=8",
"test:retry": "playwright test --retries=2",
"test:ci": "playwright test --workers=4 --retries=2",
"report": "playwright show-report",
"allure:generate": "allure generate allure-results -o allure-report --clean",
"allure:open": "allure open allure-report",
"allure:serve": "allure serve allure-results"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@playwright/test-docker": "^1.40.0",
"allure-playwright": "^2.13.0",
"dotenv": "^16.3.1"
}
}
Page Object Model Pattern
Why Page Object Model?
❌ Without POM (Scattered Locators):
test('Login', async ({ page }) => {
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button:has-text("Log In")');
await page.waitForNavigation();
});
test('Another test', async ({ page }) => {
// Duplicated locators!
await page.fill('input[name="email"]', 'user@example.com');
// ...
});
✅ With POM (Clean & Maintainable):
test('Login', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.login('user@example.com', 'password123');
});
Base Page Class
tests/fixtures/base.ts:
import { Page } from '@playwright/test';
export class BasePage {
readonly page: Page;
constructor(page: Page) {
this.page = page;
}
async goto(path: string = '/') {
await this.page.goto(path);
}
async waitForPageLoad() {
await this.page.waitForLoadState('networkidle');
}
async getPageTitle(): Promise<string> {
return await this.page.title();
}
async takeScreenshot(name: string) {
await this.page.screenshot({ path: `screenshots/${name}.png` });
}
async retryAction<T>(
action: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
let lastError: Error | null = null;
for (let i = 0; i < maxRetries; i++) {
try {
return await action();
} catch (error) {
lastError = error as Error;
if (i < maxRetries - 1) {
await this.page.waitForTimeout(delayMs);
}
}
}
throw lastError;
}
}
Complete E-Commerce Example
Login Page Object
tests/pages/login-page.ts:
import { Page, Locator } from '@playwright/test';
import { BasePage } from '../fixtures/base';
export class LoginPage extends BasePage {
readonly emailInput: Locator;
readonly passwordInput: Locator;
readonly loginButton: Locator;
readonly errorMessage: Locator;
constructor(page: Page) {
super(page);
this.emailInput = page.locator('input[type="email"]');
this.passwordInput = page.locator('input[type="password"]');
this.loginButton = page.locator('button:has-text("Log In")');
this.errorMessage = page.locator('[data-testid="error-message"]');
}
async goto() {
await super.goto('/login');
await this.waitForPageLoad();
}
async login(email: string, password: string) {
await this.emailInput.fill(email);
await this.passwordInput.fill(password);
await this.loginButton.click();
await this.page.waitForNavigation();
}
async getErrorMessage(): Promise<string> {
return (await this.errorMessage.textContent()) || '';
}
async isLoginSuccessful(): Promise<boolean> {
return this.page.url().includes('/dashboard');
}
}
Product Search Page Object
tests/pages/product-search-page.ts:
import { Page, Locator } from '@playwright/test';
import { BasePage } from '../fixtures/base';
export class ProductSearchPage extends BasePage {
readonly searchInput: Locator;
readonly searchButton: Locator;
readonly productList: Locator;
readonly firstProduct: Locator;
constructor(page: Page) {
super(page);
this.searchInput = page.locator('input[placeholder="Search products..."]');
this.searchButton = page.locator('button:has-text("Search")');
this.productList = page.locator('[data-testid="product-item"]');
this.firstProduct = this.productList.first();
}
async goto() {
await super.goto('/products');
await this.waitForPageLoad();
}
async searchProduct(productName: string) {
await this.searchInput.fill(productName);
await this.searchButton.click();
await this.page.waitForLoadState('networkidle');
}
async getProductCount(): Promise<number> {
return await this.productList.count();
}
async clickFirstProduct() {
await this.firstProduct.click();
await this.waitForPageLoad();
}
async hasResults(): Promise<boolean> {
return (await this.getProductCount()) > 0;
}
}
Complete Test Suite
tests/e2e/ecommerce-flow.test.ts:
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
import { ProductSearchPage } from '../pages/product-search-page';
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'testuser@example.com';
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'SecurePassword123!';
const PRODUCT_NAME = process.env.TEST_PRODUCT_NAME || 'Laptop';
test.describe('E-Commerce Platform', () => {
test('User completes full purchase flow', async ({ page }) => {
// Step 1: Login
await test.step('User logs in', async () => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(TEST_EMAIL, TEST_PASSWORD);
expect(await loginPage.isLoginSuccessful()).toBe(true);
});
// Step 2: Search product
await test.step('User searches for product', async () => {
const searchPage = new ProductSearchPage(page);
await searchPage.goto();
await searchPage.searchProduct(PRODUCT_NAME);
expect(await searchPage.hasResults()).toBe(true);
});
});
test('Login fails with invalid credentials', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login('invalid@example.com', 'wrongpassword');
const errorMessage = await loginPage.getErrorMessage();
expect(errorMessage).toContain('Invalid email or password');
});
});
Multi-Browser Testing
Browser Configuration
Playwright automatically runs tests across all configured browsers:
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 12'] } },
],
Running Specific Browsers
# Run on Chrome only
npm run test:chrome
# Run on Firefox only
npm run test:firefox
# Run on mobile
npm run test:mobile
# Run on all browsers (default)
npm test
Browser-Specific Assertions
test('Should work on all browsers', async ({ page, browserName }) => {
// Run only on specific browsers
if (browserName === 'chromium') {
// Chrome-specific test
}
// Browser-specific viewport handling
const viewport = page.viewportSize();
if (browserName === 'webkit') {
expect(viewport?.width).toBe(980); // Safari viewport
}
});
Parallel Execution
Playwright runs tests in parallel by default, significantly reducing total test duration. This is one of its key advantages over sequential test execution.
Understanding Parallel Execution
How it works:
- Tests are distributed across multiple worker processes
- Each worker executes tests independently
- No shared state between workers
- Tests complete in fraction of sequential time
Parallel Configuration
playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Enable parallel execution
fullyParallel: true,
// Number of parallel workers (default: number of CPU cores)
workers: process.env.CI ? 4 : undefined,
// Timeout for entire test
timeout: 30 * 1000,
// Timeout for each expect assertion
expect: { timeout: 5000 },
});
Worker Configuration
export default defineConfig({
// ✅ GOOD - Unlimited workers (uses all CPU cores)
workers: undefined, // Default behavior
// ✅ GOOD - Fixed number for consistency
workers: 4,
// ✅ GOOD - Dynamic based on environment
workers: process.env.CI ? 2 : 8,
// ❌ BAD - Single worker (no parallelization)
workers: 1,
});
Running Tests in Parallel
# Run with default parallelism (all CPU cores)
npm test
# Run with specific number of workers
npx playwright test --workers=4
# Run with 1 worker (sequential)
npx playwright test --workers=1
# Run with 2 workers
npx playwright test --workers=2
Parallel Execution Example
import { test, expect } from '@playwright/test';
// These 4 tests run in parallel (if workers >= 4)
test('User login', async ({ page }) => {
// ~2 seconds
await page.goto('/login');
// ...
});
test('Product search', async ({ page }) => {
// ~2 seconds
await page.goto('/products');
// ...
});
test('Add to cart', async ({ page }) => {
// ~2 seconds
await page.goto('/cart');
// ...
});
test('Checkout flow', async ({ page }) => {
// ~2 seconds
await page.goto('/checkout');
// ...
});
// Sequential execution: 4 tests × 2 seconds = 8 seconds
// Parallel execution (4 workers): All 4 tests = 2 seconds
Controlling Parallelism
Run tests sequentially within a file:
import { test, expect } from '@playwright/test';
test.describe.serial('User Account', () => {
// These tests run sequentially (in order)
test('User signs up', async ({ page }) => {
// Runs first
});
test('User logs in', async ({ page }) => {
// Runs second (after signup completes)
});
test('User updates profile', async ({ page }) => {
// Runs third (after login completes)
});
});
Mix serial and parallel tests:
import { test, expect } from '@playwright/test';
test.describe('E-Commerce', () => {
// Parallel tests
test('Browse products', async ({ page }) => {});
test('Search functionality', async ({ page }) => {});
test('Filter by price', async ({ page }) => {});
test.describe.serial('Checkout Process', () => {
// Serial within this describe block
test('Add to cart', async ({ page }) => {});
test('Apply coupon', async ({ page }) => {});
test('Complete payment', async ({ page }) => {});
});
});
Avoiding Test Conflicts in Parallel Execution
❌ Problem: Shared state causes failures
let userId: number;
test('Create user', async ({ page }) => {
// Test A creates user
userId = 123;
});
test('Update user', async ({ page }) => {
// Test B runs in parallel and userId is undefined!
await updateUser(userId);
});
✅ Solution: Use fixtures or beforeEach
test.beforeEach(async ({ page }) => {
// Each test gets fresh data
const response = await page.request.post('/api/users', {
data: { name: 'Test User' }
});
const { id } = await response.json();
// Available in test context
});
test('Update user', async ({ page, context }) => {
// Each test has its own user ID
const userId = context.userId;
// ...
});
Performance Tuning for Parallel Execution
export default defineConfig({
// ✅ Reduce timeout when running parallel
timeout: 30 * 1000,
expect: { timeout: 5000 },
// ✅ More workers = faster (but uses more resources)
workers: 8, // For CI
// ✅ Retry on failure (helps with flakiness)
retries: process.env.CI ? 2 : 0,
// ✅ Full parallelism across files and tests
fullyParallel: true,
// ✅ Start web server once for all workers
webServer: {
command: 'npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
});
Monitoring Parallel Execution
# Show detailed output with worker info
npx playwright test --reporter=list
# HTML report shows which worker ran which test
npm run report
# Verbose mode
npx playwright test --reporter=verbose
# Debug with trace for parallel runs
npx playwright test --trace=on
Real-World Parallel Execution Results
Sequential Execution (1 worker):
├─ Test 1: 2.5s
├─ Test 2: 2.3s
├─ Test 3: 2.1s
├─ Test 4: 2.4s
└─ Total: 9.3 seconds ❌
Parallel Execution (4 workers):
├─ Worker 1: Test 1 (2.5s)
├─ Worker 2: Test 2 (2.3s)
├─ Worker 3: Test 3 (2.1s)
├─ Worker 4: Test 4 (2.4s)
└─ Total: 2.5 seconds ✅ (3.7x faster!)
Retry & Flakiness Management
Understanding Test Flakiness
Flaky tests are tests that pass and fail unpredictably. Common causes:
- Network timeouts
- Race conditions
- Slow animations
- Database delays
- API rate limiting
- Timing-dependent assertions
Built-in Retry Mechanism
playwright.config.ts:
import { defineConfig } from '@playwright/test';
export default defineConfig({
// Retry failed tests 2 times
retries: process.env.CI ? 2 : 0,
// Retry with different config
fullyParallel: true,
// Fast fail in local development
timeout: process.env.CI ? 30000 : 10000,
});
How Retries Work
Test Run:
├─ Attempt 1: FAILED ❌
├─ Attempt 2: FAILED ❌
└─ Attempt 3: PASSED ✅ → Test marked as PASSED
HTML Report shows:
Test: "User checkout flow"
❌ Attempt 1 (2.3s) - Element not found
❌ Attempt 2 (2.1s) - Timeout waiting for navigation
✅ Attempt 3 (2.5s) - PASSED
Duration: 6.9s (including retries)
Conditional Retries
import { test, expect } from '@playwright/test';
// Only retry specific tests
test.describe('Flaky Features', () => {
test.describe.configure({ retries: 2 });
test('Slow API endpoint', async ({ page }) => {
// Will retry up to 2 times
});
test('Animation heavy page', async ({ page }) => {
// Will retry up to 2 times
});
});
// No retry for other tests
test('Critical stable test', async ({ page }) => {
// No retries (uses default: 0)
});
Manual Retry Logic with Exponential Backoff
import { Page } from '@playwright/test';
export class RetryableAction {
constructor(private page: Page) {}
async retryWithBackoff<T>(
action: () => Promise<T>,
maxRetries: number = 3,
initialDelayMs: number = 100
): Promise<T> {
let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await action();
} catch (error) {
lastError = error as Error;
if (attempt < maxRetries - 1) {
// Exponential backoff: 100ms, 200ms, 400ms
const delay = initialDelayMs * Math.pow(2, attempt);
console.log(`Attempt ${attempt + 1} failed. Retrying in ${delay}ms...`);
await this.page.waitForTimeout(delay);
}
}
}
throw new Error(
`Failed after ${maxRetries} attempts: ${lastError?.message}`
);
}
}
Using Retry in Tests
import { test, expect } from '@playwright/test';
import { RetryableAction } from './retryable-action';
test('Fetch order with retry', async ({ page }) => {
const retry = new RetryableAction(page);
const orderId = await retry.retryWithBackoff(async () => {
// API call that might fail
const response = await page.request.get('/api/orders/latest');
if (!response.ok) throw new Error('Order not found');
const data = await response.json();
return data.id;
});
expect(orderId).toBeGreaterThan(0);
});
Handling Network Timeouts
export class NetworkRetry {
constructor(private page: Page) {}
async fetchWithRetry(
url: string,
maxRetries: number = 3
): Promise<any> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await this.page.request.get(url, {
timeout: 10000, // 10 second timeout
});
if (response.status === 429) {
// Rate limited - wait longer
await this.page.waitForTimeout(2000 * (attempt + 1));
continue;
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
const backoffMs = 500 * Math.pow(2, attempt);
await this.page.waitForTimeout(backoffMs);
}
}
}
}
Flakiness Detection & Analysis
import { test, expect } from '@playwright/test';
test('Detect flaky test', async ({ page }) => {
// Use tag for analysis
test.info().tags.push('@flaky-detector');
try {
// Your test here
await page.goto('/');
await page.getByRole('button').click();
} catch (error) {
// Log detailed info for flakiness analysis
console.error('Test failed:', {
url: page.url(),
timestamp: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
screenshot: await page.screenshot({ path: 'flaky.png' }),
});
throw error;
}
});
Retry with Different Browser Settings
export default defineConfig({
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
retries: 1,
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
retries: 2, // More retries for Firefox (more flaky)
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
retries: 2, // More retries for Safari
},
],
});
Monitoring Retry Statistics
# Run tests with detailed output
npx playwright test --reporter=verbose
# Shows:
# ✓ test1 (2 attempts)
# ✓ test2 (1 attempt - passed first try)
# ✓ test3 (3 attempts)
Best Practices for Reducing Flakiness
1. Use auto-waiting instead of manual waits:
// ❌ Flaky - hardcoded wait
await page.waitForTimeout(1000);
await page.click('button');
// ✅ Reliable - auto-waiting
await page.click('button');
2. Wait for specific conditions:
// ❌ Flaky - might not be ready
const text = await page.locator('.message').textContent();
// ✅ Reliable - wait for visibility
await page.locator('.message').waitFor();
const text = await page.locator('.message').textContent();
3. Use network waits for API calls:
// ❌ Flaky - API might be slow
await page.goto('/products');
await page.waitForTimeout(500);
const count = await page.locator('[data-product]').count();
// ✅ Reliable - wait for network
await page.goto('/products');
await page.waitForLoadState('networkidle');
const count = await page.locator('[data-product]').count();
Retry Strategy Summary
| Scenario | Retry Count | Reason |
|---|---|---|
| CI Environment | 2-3 | Network delays, resource constraints |
| Flaky Endpoints | 3 | API timeouts, rate limiting |
| Mobile Tests | 2 | Slower devices, animations |
| Local Development | 0 | Immediate feedback |
| Critical Tests | 0 | Must be reliable, no masking |
Advanced Reporting with Allure
Generate Allure Reports
# Generate HTML report from results
npm run allure:generate
# Open in browser
npm run allure:open
# Serve live report
npm run allure:serve
Custom Allure Configuration
allure.properties (create in root):
allure.results.directory=allure-results
allure.report.directory=allure-report
Allure Decorators in Tests
import { test } from '@playwright/test';
import { allure } from 'allure-playwright';
test('Add product to cart', async ({ page }) => {
await allure.label('feature', 'Shopping Cart');
await allure.label('severity', 'critical');
await allure.label('owner', 'QA Team');
await allure.link('https://jira.example.com/PROJ-123', 'JIRA');
// Test code...
});
Allure Report Features
- Test execution timeline
- Failure analysis with trends
- Historical data tracking
- Environment configuration
- Test categorization by features
- Attachment support (screenshots, videos)
Common Mistakes & Solutions
❌ Mistake #1: Hard Waits (Using waitForTimeout)
Problem: Tests become flaky and slow
// ❌ BAD - Waits full 2 seconds regardless
await page.waitForTimeout(2000);
await page.click('button');
Solution: Use Playwright's built-in waiting
// ✅ GOOD - Waits for element to be ready
await page.locator('button').waitFor();
await page.click('button');
// ✅ BETTER - Auto-waiting (built-in)
await page.click('button'); // Automatically waits!
// ✅ BEST - Wait for specific state
await page.waitForLoadState('networkidle');
❌ Mistake #2: Brittle Selectors
Problem: Tests break when UI changes
// ❌ BAD - Index-based selector
await page.locator('button').nth(2).click();
// ❌ BAD - CSS selectors are fragile
await page.locator('div > div > button').click();
// ❌ BAD - Xpath is hard to maintain
await page.locator('//button[@class="btn"]').click();
Solution: Use semantic locators
// ✅ GOOD - By role (most resilient)
await page.getByRole('button', { name: 'Add to Cart' }).click();
// ✅ GOOD - By test ID
await page.getByTestId('add-to-cart-button').click();
// ✅ GOOD - By label
await page.getByLabel('Email Address').fill('test@example.com');
// ✅ GOOD - By placeholder
await page.getByPlaceholder('Search...').fill('laptop');
❌ Mistake #3: Not Handling Async Operations
Problem: Tests fail due to race conditions
// ❌ BAD - Doesn't wait for navigation
await page.click('Checkout');
const orderNum = await page.locator('[data-testid="order-number"]').textContent();
Solution: Wait for expected state changes
// ✅ GOOD - Wait for navigation
await page.click('Checkout');
await page.waitForNavigation();
const orderNum = await page.locator('[data-testid="order-number"]').textContent();
// ✅ BETTER - Wait for specific element
await page.click('Checkout');
await page.locator('[data-testid="order-number"]').waitFor();
const orderNum = await page.locator('[data-testid="order-number"]').textContent();
❌ Mistake #4: Not Cleaning Up Test Data
Problem: Test data accumulates; tests interfere with each other
// ❌ BAD - No cleanup
test('Create user', async ({ page }) => {
await page.fill('input[name="email"]', 'test@example.com');
// User left in database!
});
Solution: Clean up in afterEach
// ✅ GOOD - Cleanup
test.afterEach(async ({ page }) => {
// Delete created test data
await page.request.delete('/api/users/test@example.com');
// Clear local storage
await page.context().clearCookies();
});
❌ Mistake #5: Vague Test Names & Assertions
Problem: Hard to understand what failed
// ❌ BAD - Unclear test name
test('test1', async ({ page }) => {
expect(result).toBe(true);
});
Solution: Descriptive names & messages
// ✅ GOOD - Clear test name and assertion message
test('User can add laptop to cart with quantity 2', async ({ page }) => {
// ... test code
expect(cartTotal).toBe(expectedPrice);
});
// ✅ BETTER - With message
expect(cartTotal).toBe(expectedPrice, 'Total should reflect 2 laptops at $999 each');
❌ Mistake #6: Not Using Test Fixtures
Problem: Duplicated setup code
// ❌ BAD - Auth code repeated in every test
test('View cart', async ({ page }) => {
await page.goto('/login');
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await page.click('button');
// Now test actual feature
});
Solution: Use fixtures for setup
// ✅ GOOD - Fixture handles auth
import { test } from '../fixtures/auth';
test('View cart', async ({ authenticatedPage }) => {
// Already logged in!
await authenticatedPage.goto('/cart');
});
❌ Mistake #7: Ignoring Mobile Testing
Problem: App works on desktop but breaks on mobile
// ❌ BAD - Only desktop testing
test('Checkout flow', async ({ page }) => {
// Only runs on desktop browsers
});
Solution: Include mobile in configuration
// ✅ GOOD - Runs on desktop AND mobile
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 12'] } },
],
// ✅ MOBILE-SPECIFIC TESTS
test('Mobile checkout', async ({ page, isMobile }) => {
if (!isMobile) test.skip();
// Mobile-specific assertions
const viewport = page.viewportSize();
expect(viewport?.width).toBeLessThan(600);
});
❌ Mistake #8: Not Using test.step() for Organization
Problem: Long tests are hard to debug
// ❌ BAD - No logical steps
test('Full purchase', async ({ page }) => {
// 20+ lines of code
// Hard to find where it failed
});
Solution: Use test.step()
// ✅ GOOD - Clear step breakdown
test('Full purchase', async ({ page }) => {
await test.step('User logs in', async () => {
// Login code
});
await test.step('User searches for product', async () => {
// Search code
});
await test.step('User adds to cart', async () => {
// Cart code
});
await test.step('User completes checkout', async () => {
// Checkout code
});
});
❌ Mistake #9: Not Retrying Flaky Operations
Problem: Intermittent failures fail the entire test suite
// ❌ BAD - No retry logic
const response = await page.request.get('/api/orders');
// Fails if API is slow
Solution: Add retry logic
// ✅ GOOD - Retry with exponential backoff
async retryAction<T>(
action: () => Promise<T>,
maxRetries: number = 3,
delayMs: number = 1000
): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await action();
} catch (error) {
if (i === maxRetries - 1) throw error;
await this.page.waitForTimeout(delayMs * (i + 1)); // Exponential backoff
}
}
throw new Error('Max retries exceeded');
}
❌ Mistake #10: Not Validating Both UI and Data
Problem: UI passes but backend data is wrong
// ❌ BAD - Only UI validation
test('Order created', async ({ page }) => {
expect(await page.locator('[data-testid="success"]').isVisible()).toBe(true);
// What about the database?
});
Solution: Validate UI and backend
// ✅ GOOD - Validate both
test('Order created', async ({ page }) => {
// UI validation
expect(await page.locator('[data-testid="success"]').isVisible()).toBe(true);
// API/Database validation
const response = await page.request.get('/api/orders');
const orders = await response.json();
expect(orders.length).toBeGreaterThan(0);
expect(orders[0].status).toBe('pending');
});
CI/CD Integration (GitHub Actions)
GitHub Actions Workflow
Create .github/workflows/playwright.yml:
name: 🎭 Playwright Tests
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
schedule:
- cron: '0 2 * * *' # Run daily at 2 AM
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
browser: [chromium, firefox, webkit]
node-version: [18.x, 20.x]
steps:
- name: 📥 Check out code
uses: actions/checkout@v4
- name: 📦 Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: 📚 Install dependencies
run: npm ci
- name: 🎭 Install Playwright browsers
run: npx playwright install --with-deps
- name: 🚀 Run Playwright tests
run: npm test -- --project=${{ matrix.browser }} --workers=4
env:
BASE_URL: ${{ secrets.BASE_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
CI: true
- name: 📊 Generate Allure report
if: always()
run: npm run allure:generate
- name: 📤 Upload HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report-${{ matrix.browser }}-${{ matrix.node-version }}
path: playwright-report/
retention-days: 30
- name: 📤 Upload Allure report
if: always()
uses: actions/upload-artifact@v4
with:
name: allure-report-${{ matrix.browser }}
path: allure-report/
retention-days: 30
- name: 📤 Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results-${{ matrix.browser }}
path: test-results/
retention-days: 30
- name: 💬 Comment PR with results
if: always() && github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const testResults = JSON.parse(fs.readFileSync('test-results/results.json', 'utf8'));
const comment = `
## 🎭 Playwright Test Results - ${{ matrix.browser }}
- ✅ Passed: ${testResults.stats.expected}
- ❌ Failed: ${testResults.stats.unexpected}
- ⏭️ Skipped: ${testResults.stats.skipped}
- ⏱️ Duration: ${(testResults.stats.duration / 1000).toFixed(2)}s
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
GitHub Secrets Setup
Add these secrets in Settings → Secrets and variables → Actions:
BASE_URL=https://demo.ecommerce.com
TEST_USER_EMAIL=testuser@example.com
TEST_USER_PASSWORD=SecurePassword123!
Viewing Results
- Go to Actions tab
- Click the workflow run
- Download artifacts:
-
playwright-report- HTML test report -
allure-report- Allure report -
test-results- Raw JSON results
-
Best Practices
1. Use Semantic Locators (Priority Order)
// 1️⃣ BEST - By role (accessibility-driven)
page.getByRole('button', { name: 'Submit' });
// 2️⃣ GOOD - By test ID
page.getByTestId('submit-button');
// 3️⃣ GOOD - By label
page.getByLabel('Email');
// 4️⃣ ACCEPTABLE - By placeholder
page.getByPlaceholder('Enter email');
// 5️⃣ LAST RESORT - CSS selector
page.locator('css=button.submit');
2. Organize Tests by Feature
tests/e2e/
├── auth/
│ ├── login.test.ts
│ ├── logout.test.ts
│ └── password-reset.test.ts
├── products/
│ ├── search.test.ts
│ ├── details.test.ts
│ └── reviews.test.ts
├── checkout/
│ ├── cart.test.ts
│ ├── shipping.test.ts
│ └── payment.test.ts
└── orders/
├── create.test.ts
├── tracking.test.ts
└── returns.test.ts
3. Use Fixtures for Common Setup
// tests/fixtures/auth.ts
import { test as base } from '@playwright/test';
import { LoginPage } from '../pages/login-page';
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.login(
process.env.TEST_USER_EMAIL || '',
process.env.TEST_USER_PASSWORD || ''
);
await use();
},
});
export { expect } from '@playwright/test';
4. Environment-Specific Configuration
// Load environment variables
import dotenv from 'dotenv';
dotenv.config();
const getBaseURL = () => {
if (process.env.CI) return 'https://staging.example.com';
return process.env.BASE_URL || 'https://demo.ecommerce.com';
};
export default defineConfig({
use: {
baseURL: getBaseURL(),
},
});
5. Meaningful Test Names
// ❌ Unclear
test('test1', async ({ page }) => {});
// ✅ Clear & descriptive
test('User can add product with custom quantity to cart and see updated total', async ({ page }) => {});
Running Tests
Basic Commands
# Run all tests
npm test
# Run with browser visible
npm run test:headed
# Debug mode (step-by-step)
npm run test:debug
# Interactive UI mode
npm run test:ui
# Run specific browser
npm run test:chrome
# Run specific file
npx playwright test tests/e2e/auth.test.ts
# Run tests matching pattern
npx playwright test -g "login"
Parallel Execution Commands
# Run with default parallel workers (uses all CPU cores)
npm test
# Run with 2 parallel workers
npm run test:parallel-2
# Run with 4 parallel workers (recommended for CI)
npm run test:parallel-4
# Run with 8 parallel workers (for large test suites)
npm run test:parallel-8
# Run sequentially (1 worker - no parallelism)
npm run test:serial
Retry Configuration Commands
# Run with default retries (0 local, 2 in CI)
npm test
# Force retries (retry failed tests 2 times)
npm run test:retry
# Run with specific retry count
npx playwright test --retries=3
# No retries (fail fast)
npx playwright test --retries=0
CI/CD Command
# Run with 4 workers + 2 retries (production)
npm run test:ci
Advanced Combinations
# Parallel + retries + headed
npx playwright test --workers=4 --retries=2 --headed
# Parallel + retries + specific browser
npx playwright test --project=chromium --workers=4 --retries=2
# Parallel + retries + pattern matching
npx playwright test -g "checkout" --workers=4 --retries=2
# Debug with trace on all workers
npx playwright test --trace=on --workers=4
Reporting Commands
# View HTML report
npm run report
# View Allure report
npm run allure:open
# Serve Allure report
npm run allure:serve
# Generate Allure report
npm run allure:generate
Performance Monitoring
# Show test execution with timings
npx playwright test --reporter=verbose
# Show test execution as list
npx playwright test --reporter=list
# Show test execution as table
npx playwright test --reporter=table
Example: Production Test Run
#!/bin/bash
# production-test.sh
# Parallel execution with retries and reporting
echo "🚀 Starting production test suite..."
npm run test:ci \
--reporter=html \
--reporter=json \
--reporter=allure-playwright
if [ $? -eq 0 ]; then
echo "✅ All tests passed!"
npm run allure:serve
else
echo "❌ Tests failed. Review report:"
npm run report
fi
Performance Comparison
Running 100 tests:
Sequential (1 worker):
Total time: 5 minutes 0 seconds
Parallel (4 workers):
Total time: 1 minute 15 seconds
Speed up: 4x faster ✅
Parallel (8 workers):
Total time: 45 seconds
Speed up: 6.7x faster ✅
With retries (2 retries × 4 workers):
Initial pass: 1 min 15 sec
Retries (5 flaky tests): 15 sec
Total: 1 min 30 sec (better than sequential!)
Conclusion
Key Takeaways
✅ Playwright is production-ready - Fast, reliable, multi-browser support
✅ Page Object Model keeps tests maintainable - DRY, reusable, scalable
✅ Multi-browser testing catches real bugs - Desktop, mobile, all browsers
✅ Allure reporting provides visibility - Trends, detailed analysis, history
✅ Avoid common mistakes - Hard waits, brittle selectors, missing cleanup
✅ CI/CD integration is seamless - GitHub Actions, Jenkins, GitLab
Next Steps
- Start simple - Write a login test
- Expand coverage - Add critical user flows
- Integrate CI/CD - Set up GitHub Actions
- Monitor quality - Use Allure for insights
- Iterate - Continuously improve tests
Resources
Happy Testing! 🎭🚀
Quick Reference Card
| Command | Purpose |
|---|---|
npm test |
Run all tests |
npm run test:headed |
See browser |
npm run test:debug |
Debug step-by-step |
npm run test:ui |
Interactive mode |
npm run report |
View HTML report |
npm run allure:open |
View Allure report |
npm run test:chrome |
Test on Chrome |
npm run test:mobile |
Test on mobile |
This guide covers Playwright with Page Object Model, multi-browser testing, Allure reporting, common mistakes with solutions, and CI/CD integration. Perfect for both beginners and experienced QA engineers.
Top comments (0)