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
waitForSelectorsoup. - 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@latestThis 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(); // HoverAssertions
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 presentPage 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