Advanced Testing Patterns

End-to-End Testing with Playwright

Automate real browser testing with Playwright — the modern E2E framework with auto-wait and cross-browser support.

Why Playwright

Playwright is the recommended E2E testing framework for modern web applications:

  • Cross-browser — Chromium, Firefox, and WebKit (Safari) in one framework
  • Auto-wait — Playwright automatically waits for elements to be ready before interacting. No waitForSelector soup.
  • Fast — Tests run in parallel by default
  • TypeScript-native — Full type safety
  • Trace viewer — Debug failures by recording and replaying test execution

Installation

bash
npm init playwright@latest

This creates playwright.config.ts and an example test.

Your First E2E Test

typescript
// tests/signup.spec.ts
import { test, expect } from '@playwright/test';

test('user can sign up successfully', async ({ page }) => {
  await page.goto('/signup');

  await page.getByLabel('Full Name').fill('Alice Johnson');
  await page.getByLabel('Email').fill('alice@example.com');
  await page.getByLabel('Password').fill('SecurePass1!');
  await page.getByRole('button', { name: 'Create Account' }).click();

  // Playwright auto-waits for navigation
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByText('Welcome, Alice')).toBeVisible();
});

Locator Strategies

typescript
// By ARIA role (preferred — most semantic)
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { name: 'Login' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('checkbox', { name: 'Remember me' })

// By label text (great for form inputs)
page.getByLabel('Password')

// By visible text
page.getByText('Already have an account?')

// By placeholder
page.getByPlaceholder('Enter your email')

// By test ID (last resort)
page.getByTestId('submit-button')

// CSS selector (escape hatch)
page.locator('.error-message')

Actions

typescript
await page.getByRole('textbox').fill('Hello World');  // Type into input
await page.getByRole('button').click();               // Click
await page.getByRole('checkbox').check();             // Check checkbox
await page.getByRole('combobox').selectOption('USA'); // Select dropdown
await page.keyboard.press('Enter');                   // Keyboard press
await page.getByRole('link').hover();                 // Hover

Assertions

typescript
// All these auto-wait for the condition to be true
await expect(page).toHaveURL('/dashboard');
await expect(page.getByText('Success')).toBeVisible();
await expect(page.getByRole('button')).toBeDisabled();
await expect(page.getByRole('textbox')).toHaveValue('test@example.com');
await expect(page.getByRole('checkbox')).toBeChecked();
await expect(page.getByText('Error')).toHaveCount(0); // Element not present

Page Object Model

For maintainable E2E tests, encapsulate page interactions:

typescript
// pages/LoginPage.ts
export class LoginPage {
  constructor(private page: Page) {}

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

  async login(email: string, password: string) {
    await this.page.getByLabel('Email').fill(email);
    await this.page.getByLabel('Password').fill(password);
    await this.page.getByRole('button', { name: 'Sign In' }).click();
  }

  async getErrorMessage() {
    return this.page.getByRole('alert').textContent();
  }
}

// Use in tests
test('login with valid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('alice@example.com', 'SecurePass1!');
  await expect(page).toHaveURL('/dashboard');
});

Authentication State Reuse

typescript
// Save login state once, reuse across tests
// playwright.config.ts
export default defineConfig({
  use: {
    storageState: 'playwright/.auth/user.json',
  },
});

// tests/auth.setup.ts
import { test as setup } from '@playwright/test';

setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Email').fill('test@example.com');
  await page.getByLabel('Password').fill('SecurePass1!');
  await page.getByRole('button', { name: 'Sign In' }).click();
  await page.waitForURL('/dashboard');
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

Key Takeaways

  • Playwright auto-waits for elements to be actionable — no manual sleep() calls needed
  • Use semantic locators (getByRole, getByLabel) over CSS selectors — they match what users see
  • The Page Object Model (POM) encapsulates page interactions — DRY, maintainable tests
  • Save authenticated state once and reuse across tests — dramatically speeds up test suites
  • Run in CI with headless mode; use the trace viewer locally to debug failures

Example

typescript
// Complete E2E test for a checkout flow
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';
import { CartPage } from './pages/CartPage';
import { CheckoutPage } from './pages/CheckoutPage';

test('user can complete checkout', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('alice@example.com', 'SecurePass1!');

  await page.goto('/products/widget-pro');
  await page.getByRole('button', { name: 'Add to Cart' }).click();
  await expect(page.getByText('Added to cart')).toBeVisible();

  const cartPage = new CartPage(page);
  await cartPage.goto();
  await expect(cartPage.getItemCount()).resolves.toBe(1);
  await cartPage.proceedToCheckout();

  const checkoutPage = new CheckoutPage(page);
  await checkoutPage.fillShipping({ address: '123 Main St', city: 'Anytown' });
  await checkoutPage.fillPayment({ card: '4242424242424242' });
  await checkoutPage.placeOrder();

  await expect(page).toHaveURL(//orders/w+/confirmation/);
  await expect(page.getByText('Order confirmed')).toBeVisible();
});
Try it yourself — TYPESCRIPT