Error Handling
Why This Matters
Production test suites face unpredictable failures that would never occur in controlled development environments. Network latency spikes cause legitimate timeouts. Race conditions emerge under load. Third-party services experience momentary outages. Without graceful error handling, these transient issues trigger false negatives that erode team confidence in test results. Engineers start ignoring failing tests, defeating the entire purpose of automated testing infrastructure.
Production-grade error handling transforms brittle test suites into resilient quality gates. Retry strategies distinguish genuine regressions from environmental hiccups. Test isolation prevents cascade failures where one broken test contaminates downstream scenarios. Comprehensive failure diagnostics capture screenshots, console logs, and network traces automatically, enabling rapid debugging without manual reproduction steps. These patterns reduce mean time to resolution from hours to minutes.
The cost of poor error handling compounds over time. Development velocity slows when engineers wait for flaky test reruns. Deployment pipelines stall on intermittent failures. Critical bugs slip through when teams lose faith in test signals. Investing in robust error handling infrastructure pays dividends through increased deployment frequency, reduced incident rates, and improved developer experience.
Standard Library Approach: Try-Catch Blocks
TypeScript’s native error handling uses try-catch blocks to capture exceptions during test execution.
import { test } from "@playwright/test";
test("checkout with basic error handling", async ({ page }) => {
// => Navigate to product page with basic try-catch
// => Catches errors but provides limited debugging context
try {
await page.goto("https://shop.example.com/product/widget-pro");
// => Loads product page, may timeout on slow networks
// => No automatic retry on transient failures
await page.click('button:has-text("Add to Cart")');
// => Clicks add-to-cart button
// => Fails silently if button not yet interactive
await page.click('a:has-text("Checkout")');
// => Navigates to checkout page
// => No validation that cart actually updated
await page.fill('input[name="email"]', "test@example.com");
// => Fills email field
// => Doesn't verify field is visible/enabled
await page.fill('input[name="cardNumber"]', "4111111111111111");
// => Fills test card number
// => No handling for card validation delays
await page.click('button:has-text("Complete Purchase")');
// => Submits payment form
// => Single attempt, no retry logic
await page.waitForURL("**/confirmation");
// => Waits for confirmation page redirect
// => Throws TimeoutError on failure with no diagnostics
console.log("Order completed successfully");
// => Success log with no failure context
} catch (error) {
// => Generic catch block captures all errors
console.error("Test failed:", error.message);
// => Logs error message but loses stack trace
// => No screenshot or state capture for debugging
throw error;
// => Re-throws error to fail test
// => No cleanup or recovery attempted
}
});Limitations for production:
- No automatic retry logic: Transient failures fail tests immediately without distinguishing environmental issues from genuine bugs
- Insufficient failure diagnostics: Error messages lack screenshots, network traces, or browser console logs needed for rapid debugging
- No test isolation: Failed tests don’t clean up state, causing cascade failures in subsequent scenarios
- Manual error categorization: Engineers manually distinguish flaky tests from real failures by reviewing logs
- No soft assertion support: First assertion failure stops test execution, hiding multiple issues
- Limited failure visibility: No structured error reporting for CI/CD dashboards or alerting systems
Production Framework: Playwright Test Steps with Error Boundaries
Playwright’s test.step API creates error boundaries that isolate failures, capture diagnostics automatically, and enable granular retry strategies.
import { test, expect } from "@playwright/test";
// => Configure test with production error handling
test.describe("Checkout Flow", () => {
// => Use beforeEach for consistent test isolation
test.beforeEach(async ({ page }) => {
// => Navigate before each test for clean slate
await page.goto("https://shop.example.com");
// => Clears cookies/storage automatically via test isolation
});
test("complete purchase with production error handling", async ({ page }) => {
// => Wrap navigation in test.step for error isolation
await test.step("Load product page", async () => {
// => test.step creates error boundary with automatic retry
await page.goto("https://shop.example.com/product/widget-pro");
// => Playwright retries navigation on timeout (default: 30s)
// => Captures screenshot automatically on failure
await expect(page.locator("h1")).toContainText("Widget Pro");
// => Validates page loaded correctly before proceeding
// => Prevents cascade failures from incomplete navigation
});
await test.step("Add item to cart", async () => {
// => Isolate cart addition with separate error boundary
const addButton = page.locator('button:has-text("Add to Cart")');
// => Creates locator (lazy evaluation)
await expect(addButton).toBeVisible();
// => Validates button exists before clicking
// => Auto-waits for element to appear (default: 30s)
await addButton.click();
// => Clicks when button is actionable (not disabled)
// => Playwright retries if button intercepts click
await expect(page.locator(".cart-count")).toHaveText("1");
// => Validates cart updated correctly
// => Captures state if assertion fails
});
await test.step("Navigate to checkout", async () => {
// => Separate navigation step for targeted retry
await page.click('a:has-text("Checkout")');
// => Navigates to checkout page
await expect(page.locator("h1")).toContainText("Checkout");
// => Validates checkout page loaded
// => Fails fast if cart was empty
});
await test.step("Fill shipping information", async () => {
// => Isolate form filling from payment processing
await page.fill('input[name="email"]', "test@example.com");
// => Fills email with auto-wait for field to be editable
await page.fill('input[name="name"]', "Test User");
// => Fills name field
await page.fill('input[name="address"]', "123 Test St");
// => Fills address field
await expect(page.locator('input[name="email"]')).toHaveValue("test@example.com");
// => Validates form actually accepted input
// => Catches client-side validation failures
});
await test.step("Submit payment", async () => {
// => Separate payment step for retry/diagnostics
await page.fill('input[name="cardNumber"]', "4111111111111111");
// => Fills test card number
await page.fill('input[name="expiry"]', "12/25");
// => Fills expiration date
await page.fill('input[name="cvv"]', "123");
// => Fills CVV code
const submitButton = page.locator('button:has-text("Complete Purchase")');
// => Locates submit button
await expect(submitButton).toBeEnabled();
// => Validates payment gateway finished loading
// => Prevents clicking disabled button
await submitButton.click();
// => Submits payment form
await page.waitForURL("**/confirmation", { timeout: 60000 });
// => Extended timeout for payment processing (60s)
// => Payment gateways often have higher latency
// => Captures network trace automatically on timeout
});
await test.step("Verify order confirmation", async () => {
// => Final validation step with isolated error boundary
await expect(page.locator("h1")).toContainText("Order Confirmed");
// => Validates confirmation page loaded
const orderNumber = await page.locator('[data-testid="order-number"]').textContent();
// => Extracts order number for logging
expect(orderNumber).toMatch(/^ORD-\d{8}$/);
// => Validates order number format
// => Catches backend data inconsistencies
console.log(`Order completed: ${orderNumber}`);
// => Logs order number for audit trail
});
});
// => afterEach runs even if test fails (cleanup)
test.afterEach(async ({ page }, testInfo) => {
// => testInfo contains test status and error details
if (testInfo.status === "failed") {
// => Detect test failure for extra diagnostics
await page.screenshot({
path: `screenshots/${testInfo.title}-failure.png`,
fullPage: true,
});
// => Captures full page screenshot on failure
// => Stored in CI artifacts for debugging
}
});
});Error Handling Architecture
graph TD
A[Test Execution] -->|Step 1| B[test.step: Navigate]
B -->|Success| C[test.step: Add to Cart]
B -->|Failure| D[Error Boundary]
C -->|Success| E[test.step: Checkout]
C -->|Failure| D
E -->|Success| F[test.step: Payment]
E -->|Failure| D
F -->|Success| G[test.step: Confirmation]
F -->|Failure| D
G -->|Success| H[Test Passes]
G -->|Failure| D
D -->|Capture| I[Screenshot]
D -->|Capture| J[Console Logs]
D -->|Capture| K[Network Trace]
I --> L[Diagnostic Report]
J --> L
K --> L
L -->|Retry?| M{Retry Policy}
M -->|Transient Error| N[Retry Step]
M -->|Critical Failure| O[Fail Test]
N -->|Attempt| B
style B fill:#0173B2
style C fill:#0173B2
style E fill:#0173B2
style F fill:#0173B2
style G fill:#0173B2
style D fill:#DE8F05
style L fill:#029E73
style M fill:#CC78BC
style O fill:#CA9161
Production Patterns and Best Practices
Pattern 1: Soft Assertions with Error Accumulation
Soft assertions collect multiple failures before failing the test, revealing all issues in a single run.
import { test, expect } from "@playwright/test";
test("validate dashboard with soft assertions", async ({ page }) => {
await page.goto("https://app.example.com/dashboard");
// => Navigate to dashboard page
await test.step("Validate all widget metrics", async () => {
// => Wrap soft assertions in step for isolated failure reporting
// => Soft assertions continue execution on failure
await expect.soft(page.locator('[data-testid="revenue"]')).toContainText("$");
// => Checks revenue widget displays currency
// => Test continues even if this fails
await expect.soft(page.locator('[data-testid="active-users"]')).toContainText(/\d+/);
// => Validates active users shows numeric value
// => Continues to check remaining widgets
await expect.soft(page.locator('[data-testid="error-rate"]')).toContainText("%");
// => Validates error rate shows percentage
// => All three failures reported together if all fail
await expect.soft(page.locator('[data-testid="response-time"]')).toContainText("ms");
// => Validates response time shows milliseconds
// => Final soft assertion in group
});
// => Step fails after ALL soft assertions complete
// => Report shows all widget failures simultaneously
// => Developer fixes all issues in one iteration
});
test("validate form with accumulated errors", async ({ page }) => {
await page.goto("https://app.example.com/settings");
// => Navigate to settings page
await test.step("Fill and validate settings form", async () => {
// => Group related form validations
await page.fill('input[name="companyName"]', "Test Corp");
await page.fill('input[name="email"]', "invalid-email");
// => Intentionally invalid email for validation testing
await page.fill('input[name="phone"]', "555");
// => Intentionally invalid phone for validation testing
await page.click('button:has-text("Save")');
// => Submit form to trigger validation
// => Soft assertions capture all validation errors
await expect.soft(page.locator('.error:has-text("company name")')).toBeHidden();
// => Company name should be valid (no error)
await expect.soft(page.locator('.error:has-text("email")')).toBeVisible();
// => Email error should display
await expect.soft(page.locator('.error:has-text("phone")')).toBeVisible();
// => Phone error should display
// => All three assertions complete before step fails
// => Report shows complete validation state
});
});Pattern 2: Automatic Error Recovery with Retry Logic
Retry logic handles transient failures automatically without failing the entire test suite.
import { test, expect } from "@playwright/test";
// => Configure global retry strategy in playwright.config.ts
// retries: process.env.CI ? 2 : 0
// => Retry failed tests twice in CI, zero retries locally
test("search with automatic retry", async ({ page }) => {
await test.step("Navigate to search page", async () => {
// => Playwright retries entire step on timeout
await page.goto("https://search.example.com", {
waitUntil: "networkidle",
});
// => waitUntil: 'networkidle' ensures search UI fully loaded
// => Retries if network requests still in-flight at timeout
});
await test.step("Execute search with flaky API", async () => {
// => Wrap flaky operations in custom retry logic
let attempt = 0;
const maxAttempts = 3;
// => Allow 3 attempts for search API
while (attempt < maxAttempts) {
// => Loop until success or max attempts
try {
await page.fill('input[name="q"]', "test query");
// => Fill search input
await page.click('button:has-text("Search")');
// => Submit search form
await page.waitForSelector('[data-testid="search-results"]', {
timeout: 10000,
});
// => Wait for results container (10s timeout per attempt)
// => Throws TimeoutError if results don't load
const resultCount = await page.locator('[data-testid="result-item"]').count();
// => Count search results
expect(resultCount).toBeGreaterThan(0);
// => Validate results exist
// => Throws if zero results
console.log(`Search succeeded on attempt ${attempt + 1}`);
// => Log successful attempt for metrics
break;
// => Exit retry loop on success
} catch (error) {
// => Catch timeout or assertion errors
attempt++;
// => Increment attempt counter
if (attempt >= maxAttempts) {
// => Check if exhausted retries
throw error;
// => Re-throw error to fail test after max attempts
}
console.warn(`Search attempt ${attempt} failed, retrying...`);
// => Log retry for debugging flaky patterns
await page.reload();
// => Reload page for clean retry
// => Clears stale UI state
}
}
});
});
test("login with exponential backoff", async ({ page }) => {
await test.step("Authenticate with rate-limited API", async () => {
// => Wrap rate-limited operations in backoff logic
const maxRetries = 3;
let retryDelay = 1000;
// => Start with 1s delay
for (let attempt = 0; attempt < maxRetries; attempt++) {
// => Loop with exponential backoff
try {
await page.goto("https://app.example.com/login");
// => Navigate to login page
await page.fill('input[name="username"]', "test@example.com");
await page.fill('input[name="password"]', "testpass123");
// => Fill credentials
await page.click('button:has-text("Sign In")');
// => Submit login form
await page.waitForURL("**/dashboard", { timeout: 15000 });
// => Wait for redirect to dashboard
// => Throws TimeoutError on authentication failure
await expect(page.locator('[data-testid="user-menu"]')).toBeVisible();
// => Validate user logged in successfully
// => Throws if user menu not rendered
console.log(`Login succeeded on attempt ${attempt + 1}`);
return;
// => Exit function on success
} catch (error) {
// => Catch timeout or rate limit errors
if (attempt === maxRetries - 1) {
// => Check if final attempt
throw error;
// => Fail test after exhausting retries
}
console.warn(`Login attempt ${attempt + 1} failed, retrying in ${retryDelay}ms`);
// => Log retry timing for analysis
await page.waitForTimeout(retryDelay);
// => Wait before retry (exponential backoff)
retryDelay *= 2;
// => Double delay for next attempt (1s → 2s → 4s)
// => Respects rate limits automatically
}
}
});
});Pattern 3: Comprehensive Screenshot Capture on Failure
Automatic screenshot capture provides visual debugging context for every failure.
import { test, expect } from "@playwright/test";
test("checkout with comprehensive failure diagnostics", async ({ page }) => {
// => Configure screenshot on failure in playwright.config.ts:
// screenshot: 'only-on-failure'
// => Playwright captures screenshots automatically
test.info().annotations.push({
type: "issue",
description: "https://github.com/example/issue/123",
});
// => Link test to issue tracker for failure correlation
await test.step("Navigate to product", async () => {
await page.goto("https://shop.example.com/product/widget");
// => Playwright captures screenshot automatically if this step fails
// => Screenshot saved to test-results/ directory
await expect(page.locator("h1")).toContainText("Widget");
// => Validate page loaded correctly
// => Failure triggers screenshot capture
});
await test.step("Add to cart", async () => {
// => Capture custom screenshot before critical action
await page.screenshot({
path: `screenshots/before-add-to-cart-${Date.now()}.png`,
});
// => Baseline screenshot for comparison if test fails
await page.click('button:has-text("Add to Cart")');
// => Click add-to-cart button
await expect(page.locator(".cart-count")).toHaveText("1");
// => Validate cart updated
// => Capture screenshot after critical action
await page.screenshot({
path: `screenshots/after-add-to-cart-${Date.now()}.png`,
});
// => Shows cart state after addition
});
});
test("admin dashboard with video trace", async ({ page, context }) => {
// => Enable video recording for complex workflows
// Configure in playwright.config.ts:
// video: 'retain-on-failure'
// => Video recorded automatically, retained only on failure
await page.goto("https://admin.example.com/dashboard");
// => Video recording starts automatically
await test.step("Navigate admin menu", async () => {
await page.click('[data-testid="settings-menu"]');
// => Click settings menu (recorded in video)
await page.click('a:has-text("User Management")');
// => Navigate to user management (recorded)
await expect(page.locator("h1")).toContainText("User Management");
// => Validate navigation succeeded
});
await test.step("Create new user", async () => {
await page.click('button:has-text("Add User")');
// => Open user creation modal (recorded)
await page.fill('input[name="email"]', "newuser@example.com");
await page.fill('input[name="name"]', "New User");
// => Fill user details (recorded)
await page.click('button:has-text("Create")');
// => Submit form (recorded)
await expect(page.locator('tr:has-text("newuser@example.com")')).toBeVisible();
// => Validate user appears in table
// => Video retained automatically if this fails
// => Shows exact UI interactions leading to failure
});
// => Video saved to test-results/ if test fails
// => Provides complete visual debugging context
});
test("capture browser console errors", async ({ page }) => {
// => Listen for browser console messages
const consoleMessages: string[] = [];
// => Array to collect console output
page.on("console", (msg) => {
// => Subscribe to console events
consoleMessages.push(`${msg.type()}: ${msg.text()}`);
// => Store message type and text
// => Captures errors, warnings, logs
});
const pageErrors: string[] = [];
// => Array to collect page errors
page.on("pageerror", (error) => {
// => Subscribe to page error events
pageErrors.push(error.message);
// => Store error message
// => Captures uncaught exceptions
});
await page.goto("https://app.example.com/dashboard");
// => Navigate to dashboard
// => Console and error listeners active
await page.click('button:has-text("Generate Report")');
// => Trigger action that may cause JavaScript errors
await page.waitForTimeout(2000);
// => Wait for async operations to complete
// => Assert no JavaScript errors occurred
expect(pageErrors).toHaveLength(0);
// => Fails test if JavaScript errors detected
// => Catches client-side bugs in production
// => Log collected console messages for debugging
if (consoleMessages.length > 0) {
console.log("Console output:", consoleMessages);
// => Available in test report for analysis
}
});Trade-offs and When to Use
Try-Catch Approach:
- Use when: Prototyping tests locally with stable infrastructure, single-run smoke tests, debugging specific failure scenarios
- Benefits: Simple implementation, no framework overhead, explicit control flow
- Costs: Manual error categorization, no automatic diagnostics, limited failure visibility, high maintenance for flaky tests
test.step Error Boundaries:
- Use when: CI/CD pipelines, production monitoring, distributed team environments, flaky test management
- Benefits: Automatic screenshot/trace capture, isolated error boundaries, built-in retry logic, structured failure reporting
- Costs: Slight test execution overhead (1-2%), requires Playwright framework, steeper learning curve
Production recommendation: Use test.step error boundaries for all production test suites. The diagnostic benefits and automatic retry capabilities dramatically reduce debugging time and improve test reliability. Try-catch blocks remain useful for prototype tests and local debugging scenarios where infrastructure stability is guaranteed. For CI/CD environments, test.step’s automatic diagnostics pay for themselves by eliminating manual failure reproduction steps. The 1-2% execution overhead is negligible compared to the cost of investigating failures without screenshots or traces.
Security Considerations
Never expose credentials in error messages: Redact sensitive data before logging errors to prevent credential leaks in CI/CD logs or test reports.
try {
await page.fill('input[name="password"]', process.env.TEST_PASSWORD);
} catch (error) {
// WRONG: Logs may contain password if error includes input value
// console.error('Login failed:', error);
// RIGHT: Redact sensitive details before logging
console.error("Login failed at password field:", error.message);
throw error;
}Screenshot PII sanitization: Configure Playwright to mask sensitive data in automatic screenshots to comply with privacy regulations.
test("payment form with PII masking", async ({ page }) => {
await page.goto("https://checkout.example.com");
// => Mask sensitive fields in screenshots
await page.addStyleTag({
content: `
input[name="cardNumber"],
input[name="cvv"] {
filter: blur(5px);
}
`,
});
// => CSS blur prevents credit card numbers from appearing in screenshots
// => Applied before any screenshot capture occurs
await page.fill('input[name="cardNumber"]', "4111111111111111");
// => Screenshot would show blurred card number field
});Network trace sanitization: Filter authorization headers and API keys from captured network traces before storing in CI artifacts.
// Configure in playwright.config.ts
export default {
use: {
trace: "retain-on-failure",
// => Capture HAR (HTTP Archive) files on failure
},
};
// In test file
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.status === "failed") {
// => Custom trace sanitization on failure
const trace = await page.context().tracing.stop();
// => Stop tracing and retrieve trace file
// TODO: Implement trace sanitization to remove:
// - Authorization headers
// - API keys in request bodies
// - Session tokens in cookies
}
});Rate limit error handling: Implement exponential backoff for authentication attempts to prevent triggering security monitoring systems or account lockouts.
Common Pitfalls
Not isolating test steps: Wrapping entire test in single try-catch loses granular failure location. Use test.step to isolate each logical operation for precise error reporting.
Ignoring failure diagnostics: Running tests without screenshot/video capture forces manual reproduction of failures. Enable automatic diagnostic capture in CI/CD environments.
Infinite retry loops: Retry logic without maximum attempt limits can hang CI/CD pipelines indefinitely. Always set maxRetries and add logging for retry attempts.
Asserting inside catch blocks: Assertions in catch blocks fail silently when exceptions occur before reaching the assertion. Move validations outside error handlers.
// WRONG: Assertion in catch block
try {
await page.click("button");
await expect(page.locator(".success")).toBeVisible();
} catch (error) {
// This assertion never executes if click fails
await expect(page.locator(".error")).toBeVisible();
}
// RIGHT: Separate error path validation
try {
await page.click("button");
} catch (error) {
// Handle click failure explicitly
}
await expect(page.locator(".success")).toBeVisible();Not cleaning up after failures: Failed tests that don’t clean up state cause cascade failures in subsequent tests. Use test.afterEach with cleanup logic that runs regardless of test status.
Generic error messages: Logging “Test failed” without context makes debugging difficult. Include step name, element selector, and expected vs actual state in error messages.
Retrying non-idempotent operations: Retrying operations that mutate state (form submissions, database writes) can create duplicate records. Only retry idempotent read operations or implement idempotency keys for writes.