Skip to content

End-to-End Testing Strategies with Playwright and TypeScript

Build reliable end-to-end suites with Playwright: page object models, test isolation, network interception, visual regression and CI, without flakiness.

3 min read
A Playwright test runner showing green checkmarks for multiple browser scenarios running in parallel

Why E2E Tests Still Matter

Unit tests verify functions. Integration tests verify modules. Neither tells you whether a user can actually check out and pay. End-to-end tests fill that gap by exercising the full application from the browser down to the database. The tradeoff is speed and flakiness—but Playwright's architecture minimizes both.

Project Setup and Configuration

Playwright supports Chromium, Firefox, and WebKit from a single API. Configure it to run all three in CI but only Chromium locally for speed.

tstypescript
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
 
export default defineConfig({
  testDir: "./e2e",
  timeout: 30_000,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: process.env.CI
    ? [["html"], ["github"]]
    : [["list"]],
 
  use: {
    baseURL: "http://localhost:3000",
    screenshot: "only-on-failure",
    trace: "retain-on-failure",
    video: "retain-on-failure",
  },
 
  projects: [
    { name: "chromium", use: { ...devices["Desktop Chrome"] } },
    ...(process.env.CI
      ? [
          { name: "firefox", use: { ...devices["Desktop Firefox"] } },
          { name: "webkit", use: { ...devices["Desktop Safari"] } },
        ]
      : []),
  ],
 
  webServer: {
    command: "npm run dev",
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});

Page Object Model

Raw selectors scattered across test files become unmaintainable. Page objects encapsulate page structure and expose actions as methods.

tstypescript
// ❌ Selectors repeated across tests — one UI change breaks dozens of tests
// await page.click('[data-testid="add-to-cart"]');
// await page.fill('[data-testid="email-input"]', 'user@example.com');
 
// ✅ Page object encapsulates selectors and actions
class CheckoutPage {
  constructor(private page: Page) {}
 
  private get emailInput() {
    return this.page.getByLabel("Email address");
  }
 
  private get cartItems() {
    return this.page.getByTestId("cart-item");
  }
 
  private get placeOrderButton() {
    return this.page.getByRole("button", { name: "Place order" });
  }
 
  private get orderConfirmation() {
    return this.page.getByTestId("order-confirmation");
  }
 
  async fillEmail(email: string): Promise<void> {
    await this.emailInput.fill(email);
  }
 
  async getCartItemCount(): Promise<number> {
    return this.cartItems.count();
  }
 
  async placeOrder(): Promise<void> {
    await this.placeOrderButton.click();
    await this.orderConfirmation.waitFor({ state: "visible" });
  }
 
  async getOrderId(): Promise<string> {
    const text = await this.orderConfirmation.textContent();
    const match = text?.match(/Order #(\w+)/);
    if (!match) throw new Error("Order ID not found in confirmation");
    return match[1];
  }
}

Test Isolation with Fixtures

Every test should start from a clean state. Playwright's fixtures let you set up and tear down state per test without global side effects.

tstypescript
import { test as base, expect } from "@playwright/test";
 
interface TestFixtures {
  authenticatedPage: Page;
  testUser: { email: string; password: string };
}
 
const test = base.extend<TestFixtures>({
  testUser: async ({}, use) => {
    // Create user via API before test
    const email = `test-${Date.now()}@example.com`;
    const password = "SecureTestPass123!";
 
    const response = await fetch("http://localhost:3000/api/test/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });
 
    if (!response.ok) throw new Error("Failed to create test user");
 
    await use({ email, password });
 
    // Cleanup after test
    await fetch("http://localhost:3000/api/test/users", {
      method: "DELETE",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email }),
    });
  },
 
  authenticatedPage: async ({ page, testUser }, use) => {
    await page.goto("/login");
    await page.getByLabel("Email").fill(testUser.email);
    await page.getByLabel("Password").fill(testUser.password);
    await page.getByRole("button", { name: "Sign in" }).click();
    await page.waitForURL("/dashboard");
 
    await use(page);
  },
});
 
test("authenticated user can view dashboard", async ({
  authenticatedPage,
}) => {
  await expect(
    authenticatedPage.getByRole("heading", { name: "Dashboard" })
  ).toBeVisible();
});

Network Interception

Mock external APIs to make tests deterministic. Playwright's route interception handles this without a separate mock server.

tstypescript
test("displays payment error gracefully", async ({ page }) => {
  // Intercept payment API to simulate failure
  await page.route("**/api/payments", (route) => {
    route.fulfill({
      status: 422,
      contentType: "application/json",
      body: JSON.stringify({
        error: "card_declined",
        message: "Your card was declined",
      }),
    });
  });
 
  await page.goto("/checkout");
  await page.getByLabel("Card number").fill("4242424242424242");
  await page.getByRole("button", { name: "Pay" }).click();
 
  await expect(
    page.getByText("Your card was declined")
  ).toBeVisible();
 
  // Verify the error state does not break the page
  await expect(
    page.getByRole("button", { name: "Pay" })
  ).toBeEnabled();
});
 
test("handles slow API responses", async ({ page }) => {
  await page.route("**/api/products", async (route) => {
    // Simulate 3-second delay
    await new Promise((r) => setTimeout(r, 3000));
    await route.continue();
  });
 
  await page.goto("/products");
 
  // Loading state should appear
  await expect(page.getByTestId("loading-skeleton")).toBeVisible();
 
  // Products should eventually load
  await expect(page.getByTestId("product-card").first()).toBeVisible({
    timeout: 10_000,
  });
});

Visual Regression Testing

Catch unintended visual changes by comparing screenshots against baselines.

tstypescript
test("product card renders correctly", async ({ page }) => {
  await page.goto("/products");
 
  const productCard = page.getByTestId("product-card").first();
  await expect(productCard).toHaveScreenshot("product-card.png", {
    maxDiffPixelRatio: 0.01,
  });
});
 
test("responsive layout at mobile viewport", async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 });
  await page.goto("/");
 
  // Full page screenshot comparison
  await expect(page).toHaveScreenshot("homepage-mobile.png", {
    fullPage: true,
    maxDiffPixelRatio: 0.02,
  });
});

Debugging Failed Tests

Playwright's trace viewer shows every action, network request, and DOM snapshot from a failed test. Configure traces to capture on failure automatically, then open them locally.

tstypescript
// View trace from a failed CI run
// npx playwright show-trace trace.zip
 
test("complex checkout flow", async ({ page, context }) => {
  // Start tracing for this specific test
  await context.tracing.start({
    screenshots: true,
    snapshots: true,
    sources: true,
  });
 
  try {
    await page.goto("/checkout");
    // ... test steps
  } finally {
    await context.tracing.stop({
      path: "checkout-trace.zip",
    });
  }
});

Key Takeaways

End-to-end tests should cover critical user paths: authentication, checkout, data entry, and error recovery. Use page objects to isolate selector changes from test logic. Create fresh state per test with fixtures—shared state between tests is the primary source of flakiness.

Intercept network requests to test error states, slow responses, and edge cases that are impossible to reproduce with a live backend. Use visual regression testing for components where pixel-level accuracy matters, but keep the threshold reasonable to avoid false positives from font rendering differences. Enable traces and screenshots on failure in CI—debugging a failed E2E test without a trace is like debugging a production incident without logs.

Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX