DEV Community

Cover image for End-to-End Testing with Playwright: Complete Guide with Page Object Model
Satish Reddy Budati
Satish Reddy Budati

Posted on

End-to-End Testing with Playwright: Complete Guide with Page Object Model

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

  1. Why Playwright?
  2. Project Setup
  3. Page Object Model Pattern
  4. Complete E-Commerce Example
  5. Multi-Browser Testing
  6. Parallel Execution
  7. Retry & Flakiness Management
  8. Advanced Reporting with Allure
  9. Common Mistakes & Solutions
  10. CI/CD Integration (GitHub Actions)
  11. Best Practices
  12. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 3: Install Allure Reporter

npm install -D allure-playwright
npm install -D @playwright/test
npm install -g allure-commandline
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  },
});
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  // ...
});
Enter fullscreen mode Exit fullscreen mode

✅ With POM (Clean & Maintainable):

test('Login', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.login('user@example.com', 'password123');
});
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
  });
});
Enter fullscreen mode Exit fullscreen mode

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'] } },
],
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
  }
});
Enter fullscreen mode Exit fullscreen mode

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 },
});
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
  });
});
Enter fullscreen mode Exit fullscreen mode

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 }) => {});
  });
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

✅ 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;
  // ...
});
Enter fullscreen mode Exit fullscreen mode

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,
  },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

How Retries Work

Test Run:
├─ Attempt 1: FAILED ❌
├─ Attempt 2: FAILED ❌
└─ Attempt 3: PASSED ✅ → Test marked as PASSED
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
});
Enter fullscreen mode Exit fullscreen mode

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}`
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
});
Enter fullscreen mode Exit fullscreen mode

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
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Custom Allure Configuration

allure.properties (create in root):

allure.results.directory=allure-results
allure.report.directory=allure-report
Enter fullscreen mode Exit fullscreen mode

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...
});
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

❌ 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();
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

❌ 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();
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

❌ 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!
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

❌ Mistake #5: Vague Test Names & Assertions

Problem: Hard to understand what failed

// ❌ BAD - Unclear test name
test('test1', async ({ page }) => {
  expect(result).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

❌ 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
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

❌ 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
});
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

❌ 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
});
Enter fullscreen mode Exit fullscreen mode

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
  });
});
Enter fullscreen mode Exit fullscreen mode

❌ 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
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

❌ 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?
});
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

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
            });
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

Viewing Results

  1. Go to Actions tab
  2. Click the workflow run
  3. 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');
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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(),
  },
});
Enter fullscreen mode Exit fullscreen mode

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 }) => {});
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

CI/CD Command

# Run with 4 workers + 2 retries (production)
npm run test:ci
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!)
Enter fullscreen mode Exit fullscreen mode

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

  1. Start simple - Write a login test
  2. Expand coverage - Add critical user flows
  3. Integrate CI/CD - Set up GitHub Actions
  4. Monitor quality - Use Allure for insights
  5. 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)