Front-end Engineering Lab
PatternsTesting Strategies

E2E Testing Patterns

End-to-end testing best practices with Playwright for reliable automated tests

E2E Testing Patterns

End-to-end tests verify complete user flows from start to finish. When done right, they catch bugs that unit and integration tests miss. When done wrong, they become slow, flaky, and maintenance nightmares.

Playwright Setup

npm init playwright@latest
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [
    ['html'],
    ['junit', { outputFile: 'test-results/junit.xml' }],
  ],
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },

  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'] },
    },
  ],

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Page Object Model (POM)

Why POM?

// ❌ BAD: Test logic mixed with locators
test('user can checkout', async ({ page }) => {
  await page.goto('/');
  await page.click('[data-testid="product-1"]');
  await page.click('[data-testid="add-to-cart"]');
  await page.click('[data-testid="cart-icon"]');
  await page.click('[data-testid="checkout"]');
  // ... 50 more lines of selectors
});

// ✅ GOOD: Page Object Model
test('user can checkout', async ({ page }) => {
  const homePage = new HomePage(page);
  const productPage = new ProductPage(page);
  const cartPage = new CartPage(page);
  const checkoutPage = new CheckoutPage(page);

  await homePage.goto();
  await homePage.selectProduct('Product 1');
  await productPage.addToCart();
  await cartPage.goto();
  await cartPage.proceedToCheckout();
  await checkoutPage.complete({
    email: 'test@example.com',
    card: '4242424242424242',
  });

  await expect(page).toHaveURL(/\/order-confirmation/);
});

Creating Page Objects

// pages/HomePage.ts
import { Page, Locator } from '@playwright/test';

export class HomePage {
  readonly page: Page;
  readonly searchInput: Locator;
  readonly productCards: Locator;

  constructor(page: Page) {
    this.page = page;
    this.searchInput = page.getByRole('searchbox', { name: /search/i });
    this.productCards = page.getByTestId('product-card');
  }

  async goto() {
    await this.page.goto('/');
    await this.page.waitForLoadState('networkidle');
  }

  async search(query: string) {
    await this.searchInput.fill(query);
    await this.searchInput.press('Enter');
    await this.page.waitForURL('**/search?q=**');
  }

  async selectProduct(name: string) {
    await this.page
      .getByRole('link', { name })
      .click();
    
    await this.page.waitForURL('**/products/**');
  }

  async getProductCount() {
    return await this.productCards.count();
  }
}
// pages/ProductPage.ts
export class ProductPage {
  readonly page: Page;
  readonly addToCartButton: Locator;
  readonly quantityInput: Locator;
  readonly priceElement: Locator;

  constructor(page: Page) {
    this.page = page;
    this.addToCartButton = page.getByRole('button', { name: /add to cart/i });
    this.quantityInput = page.getByLabel('Quantity');
    this.priceElement = page.getByTestId('product-price');
  }

  async addToCart(quantity = 1) {
    if (quantity > 1) {
      await this.quantityInput.fill(quantity.toString());
    }
    
    await this.addToCartButton.click();
    
    // Wait for confirmation
    await this.page.getByText('Added to cart').waitFor();
  }

  async getPrice() {
    const text = await this.priceElement.textContent();
    return parseFloat(text!.replace('$', ''));
  }
}

Testing Patterns

1. Authentication

// auth.setup.ts - Run once, reuse in tests
import { test as setup } from '@playwright/test';

const authFile = 'playwright/.auth/user.json';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('password123');
  await page.getByRole('button', { name: /sign in/i }).click();
  
  await page.waitForURL('/dashboard');
  
  // Save auth state
  await page.context().storageState({ path: authFile });
});

// Use in tests
import { test } from '@playwright/test';

test.use({ storageState: 'playwright/.auth/user.json' });

test('view dashboard', async ({ page }) => {
  await page.goto('/dashboard');
  // Already authenticated!
});

2. API Mocking

// Mock API responses
test('displays products', async ({ page }) => {
  // Mock API call
  await page.route('**/api/products', async route => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify({
        products: [
          { id: 1, name: 'Product 1', price: 10 },
          { id: 2, name: 'Product 2', price: 20 },
        ],
      }),
    });
  });

  await page.goto('/products');
  
  await expect(page.getByText('Product 1')).toBeVisible();
  await expect(page.getByText('Product 2')).toBeVisible();
});

3. Waiting Strategies

// ❌ BAD: Fixed waits
await page.click('button');
await page.waitForTimeout(3000); // Arbitrary delay

// ✅ GOOD: Smart waits
await page.click('button');
await page.waitForURL('**/success');

// Wait for element
await page.waitForSelector('[data-testid="confirmation"]');

// Wait for network
await page.waitForLoadState('networkidle');

// Wait for custom condition
await page.waitForFunction(() => {
  return document.querySelector('[data-testid="loaded"]') !== null;
});

4. Parallel Execution

// Each test runs in parallel
test.describe.configure({ mode: 'parallel' });

test.describe('Product Tests', () => {
  test('can view product 1', async ({ page }) => {
    // Runs in parallel
  });

  test('can view product 2', async ({ page }) => {
    // Runs in parallel
  });
});

// Run tests in series
test.describe.configure({ mode: 'serial' });

test.describe('Checkout Flow', () => {
  test('step 1: add to cart', async ({ page }) => {
    // Must complete before step 2
  });

  test('step 2: checkout', async ({ page }) => {
    // Runs after step 1
  });
});

5. Data-Driven Tests

const products = [
  { name: 'Product 1', price: 10 },
  { name: 'Product 2', price: 20 },
  { name: 'Product 3', price: 30 },
];

for (const product of products) {
  test(`can purchase ${product.name}`, async ({ page }) => {
    await page.goto('/');
    await page.getByText(product.name).click();
    await page.getByRole('button', { name: /buy now/i }).click();
    
    await expect(page.getByText(`Total: $${product.price}`)).toBeVisible();
  });
}

Best Practices

1. Use Role Selectors

// ❌ BAD: Brittle selectors
await page.click('.btn.btn-primary.submit-btn');
await page.click('#email-input');

// ✅ GOOD: Semantic selectors
await page.getByRole('button', { name: /submit/i }).click();
await page.getByLabel('Email').fill('test@example.com');

2. Test User Flows, Not Pages

// ❌ BAD: Testing individual pages
test('homepage loads', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle('Home');
});

test('product page loads', async ({ page }) => {
  await page.goto('/products/1');
  await expect(page.getByTestId('product')).toBeVisible();
});

// ✅ GOOD: Testing complete flows
test('user can browse and purchase product', async ({ page }) => {
  // Complete user journey
  await page.goto('/');
  await page.getByRole('link', { name: 'Shop' }).click();
  await page.getByText('Product 1').click();
  await page.getByRole('button', { name: /add to cart/i }).click();
  await page.getByRole('button', { name: /checkout/i }).click();
  
  // Verify purchase
  await expect(page).toHaveURL(/\/order-confirmation/);
});

3. Handle Flaky Tests

// Retry flaky tests
test('flaky operation', async ({ page }) => {
  // This test retries automatically based on config
  await page.goto('/');
});

// Or override for specific test
test('specific flaky test', async ({ page }) => {
  test.slow(); // 3x timeout for this test
  
  await page.goto('/slow-page');
});

// Custom retry logic
test('with custom retry', async ({ page }) => {
  await test.step('navigate', async () => {
    await page.goto('/');
  });

  await test.step('interact', async () => {
    await page.click('button');
  });
});

4. Organize Tests

tests/e2e/
├── auth/
│   ├── login.spec.ts
│   ├── signup.spec.ts
│   └── logout.spec.ts

├── checkout/
│   ├── guest-checkout.spec.ts
│   ├── member-checkout.spec.ts
│   └── payment-methods.spec.ts

├── product/
│   ├── browse.spec.ts
│   ├── search.spec.ts
│   └── filters.spec.ts

└── pages/
    ├── HomePage.ts
    ├── ProductPage.ts
    └── CheckoutPage.ts

5. Debug Failing Tests

// Enable debugging
test('debug mode', async ({ page }) => {
  // Pause execution
  await page.pause();
  
  // Slow down
  await page.setViewportSize({ width: 1280, height: 720 });
  await page.waitForTimeout(1000);
  
  // Take screenshot
  await page.screenshot({ path: 'debug.png', fullPage: true });
  
  // Print page content
  console.log(await page.content());
});

6. CI/CD Integration

# .github/workflows/e2e.yml
name: E2E Tests

on: [push, pull_request]

jobs:
  e2e:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '20'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps
      
      - name: Build
        run: npm run build
      
      - name: Run E2E tests
        run: npx playwright test
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/

Common Pitfalls

Too many E2E tests: Slow, expensive, hard to maintain
Focus on critical paths only

Testing implementation details: Breaks with refactoring
Test user behavior

Fixed waits: waitForTimeout(3000)
Smart waits: waitForSelector, waitForURL

No Page Objects: Repeated selectors everywhere
Use Page Object Model

Running in series: Slow feedback
Run in parallel

Test Prioritization

PriorityTestsWhy
P0Auth, Checkout, PaymentsBusiness critical
P1Search, Filters, CartCore features
P2Profile, SettingsImportant but not blocking
P3UI polish, animationsNice to have

Run P0 on every commit, P1-P3 nightly or pre-deployment.

E2E tests are expensive—use them wisely to test what matters most.

On this page