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.

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.
// 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.
// ❌ 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.
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.
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.
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.
// 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.


