SurfSense/.cursor/skills/playwright-testing/error-and-edge-cases.md
2026-05-04 13:54:13 +05:30

38 KiB
Executable file

Error States and Edge Cases

When to use: Testing how your application handles errors, failures, boundary conditions, and unusual user behavior. These tests catch bugs that happy-path tests miss. Prerequisites: core/assertions-and-waiting.md, core/network-mocking.md for route interception

Quick Reference

// Mock a 500 server error
await page.route('**/api/data', (route) => route.fulfill({ status: 500 }));

// Simulate offline mode
await page.context().setOffline(true);

// Test empty state
await page.route('**/api/items', (route) =>
  route.fulfill({ status: 200, json: [] })
);

// Browser back/forward
await page.goBack();
await page.goForward();

// Abort a network request (simulate network failure)
await page.route('**/api/save', (route) => route.abort('connectionfailed'));

Patterns

HTTP Error Status Codes

Use when: Testing that your application displays appropriate error pages or messages for 4xx and 5xx responses. Avoid when: The error is handled silently (no user-facing feedback). Test via API or logs instead.

TypeScript

import { test, expect } from '@playwright/test';

test('displays 404 page for missing resources', async ({ page }) => {
  // Navigate directly to a non-existent URL
  await page.goto('/this-page-does-not-exist');

  await expect(page.getByRole('heading', { name: /not found/i })).toBeVisible();
  await expect(page.getByRole('link', { name: 'Go home' })).toBeVisible();
});

test('handles 500 server error gracefully', async ({ page }) => {
  // Intercept the API call and return a 500
  await page.route('**/api/dashboard', (route) =>
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    })
  );

  await page.goto('/dashboard');

  await expect(page.getByText('Something went wrong')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
});

test('handles 403 forbidden with redirect to login', async ({ page }) => {
  await page.route('**/api/admin/**', (route) =>
    route.fulfill({ status: 403 })
  );

  await page.goto('/admin/settings');

  // Should redirect to login or show access denied
  await expect(page.getByText(/access denied|not authorized/i)).toBeVisible();
});

test('handles 429 rate limiting', async ({ page }) => {
  await page.route('**/api/search*', (route) =>
    route.fulfill({
      status: 429,
      headers: { 'Retry-After': '30' },
      body: JSON.stringify({ error: 'Too many requests' }),
    })
  );

  await page.goto('/search');
  await page.getByLabel('Search').fill('test');
  await page.getByRole('button', { name: 'Search' }).click();

  await expect(page.getByText(/too many requests|try again later/i)).toBeVisible();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('displays 404 page for missing resources', async ({ page }) => {
  await page.goto('/this-page-does-not-exist');

  await expect(page.getByRole('heading', { name: /not found/i })).toBeVisible();
  await expect(page.getByRole('link', { name: 'Go home' })).toBeVisible();
});

test('handles 500 server error gracefully', async ({ page }) => {
  await page.route('**/api/dashboard', (route) =>
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Internal server error' }),
    })
  );

  await page.goto('/dashboard');

  await expect(page.getByText('Something went wrong')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
});

test('handles 403 forbidden with redirect to login', async ({ page }) => {
  await page.route('**/api/admin/**', (route) =>
    route.fulfill({ status: 403 })
  );

  await page.goto('/admin/settings');
  await expect(page.getByText(/access denied|not authorized/i)).toBeVisible();
});

test('handles 429 rate limiting', async ({ page }) => {
  await page.route('**/api/search*', (route) =>
    route.fulfill({
      status: 429,
      headers: { 'Retry-After': '30' },
      body: JSON.stringify({ error: 'Too many requests' }),
    })
  );

  await page.goto('/search');
  await page.getByLabel('Search').fill('test');
  await page.getByRole('button', { name: 'Search' }).click();

  await expect(page.getByText(/too many requests|try again later/i)).toBeVisible();
});

Network Failure and Offline Mode

Use when: Testing how the app behaves when the network is down, requests fail, or the connection is intermittent. Avoid when: The app has no offline or error handling behavior to test.

TypeScript

import { test, expect } from '@playwright/test';

test('offline mode shows offline banner', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  // Go offline
  await page.context().setOffline(true);

  // Trigger a network-dependent action
  await page.getByRole('button', { name: 'Refresh' }).click();

  await expect(page.getByText(/offline|no connection/i)).toBeVisible();

  // Go back online
  await page.context().setOffline(false);

  await page.getByRole('button', { name: 'Refresh' }).click();
  await expect(page.getByText(/offline|no connection/i)).not.toBeVisible();
});

test('network request failure shows error state', async ({ page }) => {
  // Abort specific requests to simulate network failure
  await page.route('**/api/user/profile', (route) =>
    route.abort('connectionfailed')
  );

  await page.goto('/profile');

  await expect(page.getByText('Failed to load profile')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('request timeout shows timeout message', async ({ page }) => {
  // Delay the response beyond the app's timeout threshold
  await page.route('**/api/reports', async (route) => {
    await new Promise((resolve) => setTimeout(resolve, 30_000));
    await route.fulfill({ status: 200, json: { data: [] } });
  });

  await page.goto('/reports');

  // App should show timeout message before Playwright's own timeout
  await expect(page.getByText(/timed out|taking too long/i)).toBeVisible({
    timeout: 20_000,
  });
});

test('intermittent connectivity — request fails then succeeds', async ({ page }) => {
  let requestCount = 0;

  await page.route('**/api/data', (route) => {
    requestCount++;
    if (requestCount <= 2) {
      return route.abort('connectionfailed');
    }
    return route.fulfill({ status: 200, json: { items: ['a', 'b', 'c'] } });
  });

  await page.goto('/data');

  // First load fails
  await expect(page.getByText(/failed|error/i)).toBeVisible();

  // User retries — still fails
  await page.getByRole('button', { name: 'Retry' }).click();
  await expect(page.getByText(/failed|error/i)).toBeVisible();

  // Third attempt succeeds
  await page.getByRole('button', { name: 'Retry' }).click();
  await expect(page.getByRole('listitem')).toHaveCount(3);
});

JavaScript

const { test, expect } = require('@playwright/test');

test('offline mode shows offline banner', async ({ page }) => {
  await page.goto('/dashboard');
  await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();

  await page.context().setOffline(true);

  await page.getByRole('button', { name: 'Refresh' }).click();
  await expect(page.getByText(/offline|no connection/i)).toBeVisible();

  await page.context().setOffline(false);

  await page.getByRole('button', { name: 'Refresh' }).click();
  await expect(page.getByText(/offline|no connection/i)).not.toBeVisible();
});

test('network request failure shows error state', async ({ page }) => {
  await page.route('**/api/user/profile', (route) =>
    route.abort('connectionfailed')
  );

  await page.goto('/profile');

  await expect(page.getByText('Failed to load profile')).toBeVisible();
  await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
});

test('intermittent connectivity — request fails then succeeds', async ({ page }) => {
  let requestCount = 0;

  await page.route('**/api/data', (route) => {
    requestCount++;
    if (requestCount <= 2) {
      return route.abort('connectionfailed');
    }
    return route.fulfill({ status: 200, json: { items: ['a', 'b', 'c'] } });
  });

  await page.goto('/data');

  await expect(page.getByText(/failed|error/i)).toBeVisible();

  await page.getByRole('button', { name: 'Retry' }).click();
  await expect(page.getByText(/failed|error/i)).toBeVisible();

  await page.getByRole('button', { name: 'Retry' }).click();
  await expect(page.getByRole('listitem')).toHaveCount(3);
});

Empty States and Boundary Testing

Use when: Testing what the UI shows when there is no data, when inputs are at their minimum or maximum values, or when inputs contain special characters. Avoid when: Never. Every feature should have empty state and boundary tests.

TypeScript

import { test, expect } from '@playwright/test';

test('empty state is shown when no items exist', async ({ page }) => {
  // Mock an empty response
  await page.route('**/api/tasks', (route) =>
    route.fulfill({ status: 200, json: [] })
  );

  await page.goto('/tasks');

  await expect(page.getByText('No tasks yet')).toBeVisible();
  await expect(page.getByRole('link', { name: 'Create your first task' })).toBeVisible();

  // List elements should not be present
  await expect(page.getByRole('listitem')).toHaveCount(0);
});

test('handles maximum length input', async ({ page }) => {
  await page.goto('/profile');

  // Fill with max-length string
  const maxLengthName = 'A'.repeat(255);
  await page.getByLabel('Display name').fill(maxLengthName);
  await page.getByRole('button', { name: 'Save' }).click();

  // Verify the name was saved (or truncated, depending on app behavior)
  await expect(page.getByText('Profile updated')).toBeVisible();
});

test('handles special characters in input', async ({ page }) => {
  await page.goto('/search');

  const specialInputs = [
    '<script>alert("xss")</script>',
    '"; DROP TABLE users; --',
    'unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80',
    'null bytes: \x00\x01\x02',
    'path traversal: ../../etc/passwd',
  ];

  for (const input of specialInputs) {
    await page.getByLabel('Search').fill(input);
    await page.getByRole('button', { name: 'Search' }).click();

    // App should not crash — either show results or "no results"
    await expect(
      page.getByText(/results|no results|no matches/i)
    ).toBeVisible();
  }
});

test('handles zero, one, and many items (0-1-N pattern)', async ({ page }) => {
  // Zero items
  await page.route('**/api/notifications', (route) =>
    route.fulfill({ status: 200, json: [] })
  );
  await page.goto('/notifications');
  await expect(page.getByText('No notifications')).toBeVisible();

  // One item
  await page.route('**/api/notifications', (route) =>
    route.fulfill({
      status: 200,
      json: [{ id: 1, message: 'Welcome!' }],
    })
  );
  await page.reload();
  await expect(page.getByRole('listitem')).toHaveCount(1);
  await expect(page.getByText('No notifications')).not.toBeVisible();

  // Many items — verify pagination or "load more"
  await page.route('**/api/notifications', (route) =>
    route.fulfill({
      status: 200,
      json: Array.from({ length: 50 }, (_, i) => ({
        id: i + 1,
        message: `Notification ${i + 1}`,
      })),
    })
  );
  await page.reload();
  await expect(page.getByRole('listitem').first()).toBeVisible();
  await expect(page.getByRole('button', { name: /load more|show all/i })).toBeVisible();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('empty state is shown when no items exist', async ({ page }) => {
  await page.route('**/api/tasks', (route) =>
    route.fulfill({ status: 200, json: [] })
  );

  await page.goto('/tasks');

  await expect(page.getByText('No tasks yet')).toBeVisible();
  await expect(page.getByRole('link', { name: 'Create your first task' })).toBeVisible();
  await expect(page.getByRole('listitem')).toHaveCount(0);
});

test('handles maximum length input', async ({ page }) => {
  await page.goto('/profile');

  const maxLengthName = 'A'.repeat(255);
  await page.getByLabel('Display name').fill(maxLengthName);
  await page.getByRole('button', { name: 'Save' }).click();

  await expect(page.getByText('Profile updated')).toBeVisible();
});

test('handles special characters in input', async ({ page }) => {
  await page.goto('/search');

  const specialInputs = [
    '<script>alert("xss")</script>',
    '"; DROP TABLE users; --',
    'unicode: \u00e9\u00e0\u00fc\u00f1 \u4f60\u597d \ud83d\ude80',
    'path traversal: ../../etc/passwd',
  ];

  for (const input of specialInputs) {
    await page.getByLabel('Search').fill(input);
    await page.getByRole('button', { name: 'Search' }).click();

    await expect(
      page.getByText(/results|no results|no matches/i)
    ).toBeVisible();
  }
});

test('handles zero, one, and many items (0-1-N pattern)', async ({ page }) => {
  await page.route('**/api/notifications', (route) =>
    route.fulfill({ status: 200, json: [] })
  );
  await page.goto('/notifications');
  await expect(page.getByText('No notifications')).toBeVisible();

  await page.route('**/api/notifications', (route) =>
    route.fulfill({
      status: 200,
      json: [{ id: 1, message: 'Welcome!' }],
    })
  );
  await page.reload();
  await expect(page.getByRole('listitem')).toHaveCount(1);

  await page.route('**/api/notifications', (route) =>
    route.fulfill({
      status: 200,
      json: Array.from({ length: 50 }, (_, i) => ({
        id: i + 1,
        message: `Notification ${i + 1}`,
      })),
    })
  );
  await page.reload();
  await expect(page.getByRole('listitem').first()).toBeVisible();
  await expect(page.getByRole('button', { name: /load more|show all/i })).toBeVisible();
});

Loading States and Skeletons

Use when: Verifying that loading indicators, skeleton screens, or spinners appear during data fetching and disappear when data arrives. Avoid when: The application renders synchronously with no loading indicators (SSR without client-side fetching).

TypeScript

import { test, expect } from '@playwright/test';

test('loading skeleton appears and resolves', async ({ page }) => {
  // Delay the API response to observe the loading state
  let resolveResponse: () => void;
  const responseReady = new Promise<void>((resolve) => {
    resolveResponse = resolve;
  });

  await page.route('**/api/dashboard', async (route) => {
    await responseReady;
    await route.fulfill({
      status: 200,
      json: { revenue: 12400, users: 350 },
    });
  });

  await page.goto('/dashboard');

  // Skeleton should be visible while loading
  await expect(page.getByTestId('skeleton-revenue')).toBeVisible();
  await expect(page.getByTestId('skeleton-users')).toBeVisible();

  // Real content should not be visible yet
  await expect(page.getByText('$12,400')).not.toBeVisible();

  // Release the response
  resolveResponse!();

  // Skeleton disappears, real content appears
  await expect(page.getByTestId('skeleton-revenue')).not.toBeVisible();
  await expect(page.getByText('$12,400')).toBeVisible();
  await expect(page.getByText('350')).toBeVisible();
});

test('spinner shown during form submission', async ({ page }) => {
  let resolveSubmit: () => void;
  const submitReady = new Promise<void>((resolve) => {
    resolveSubmit = resolve;
  });

  await page.route('**/api/contact', async (route) => {
    await submitReady;
    await route.fulfill({ status: 200, json: { success: true } });
  });

  await page.goto('/contact');
  await page.getByLabel('Name').fill('Jane');
  await page.getByLabel('Email').fill('jane@example.com');
  await page.getByLabel('Message').fill('Test');

  await page.getByRole('button', { name: 'Send' }).click();

  // Loading spinner/state during submission
  await expect(page.getByRole('button', { name: /sending/i })).toBeVisible();
  await expect(page.getByRole('button', { name: /sending/i })).toBeDisabled();

  // Complete the submission
  resolveSubmit!();

  await expect(page.getByText('Message sent')).toBeVisible();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('loading skeleton appears and resolves', async ({ page }) => {
  let resolveResponse;
  const responseReady = new Promise((resolve) => {
    resolveResponse = resolve;
  });

  await page.route('**/api/dashboard', async (route) => {
    await responseReady;
    await route.fulfill({
      status: 200,
      json: { revenue: 12400, users: 350 },
    });
  });

  await page.goto('/dashboard');

  await expect(page.getByTestId('skeleton-revenue')).toBeVisible();
  await expect(page.getByTestId('skeleton-users')).toBeVisible();
  await expect(page.getByText('$12,400')).not.toBeVisible();

  resolveResponse();

  await expect(page.getByTestId('skeleton-revenue')).not.toBeVisible();
  await expect(page.getByText('$12,400')).toBeVisible();
  await expect(page.getByText('350')).toBeVisible();
});

test('spinner shown during form submission', async ({ page }) => {
  let resolveSubmit;
  const submitReady = new Promise((resolve) => {
    resolveSubmit = resolve;
  });

  await page.route('**/api/contact', async (route) => {
    await submitReady;
    await route.fulfill({ status: 200, json: { success: true } });
  });

  await page.goto('/contact');
  await page.getByLabel('Name').fill('Jane');
  await page.getByLabel('Email').fill('jane@example.com');
  await page.getByLabel('Message').fill('Test');

  await page.getByRole('button', { name: 'Send' }).click();

  await expect(page.getByRole('button', { name: /sending/i })).toBeVisible();
  await expect(page.getByRole('button', { name: /sending/i })).toBeDisabled();

  resolveSubmit();

  await expect(page.getByText('Message sent')).toBeVisible();
});

Retry Behavior Testing

Use when: Testing that the application retries failed requests automatically or via a user-triggered "retry" button. Avoid when: The application has no retry mechanism.

TypeScript

import { test, expect } from '@playwright/test';

test('retry button recovers from a failed API call', async ({ page }) => {
  let callCount = 0;

  await page.route('**/api/feed', (route) => {
    callCount++;
    if (callCount === 1) {
      return route.fulfill({ status: 500 });
    }
    return route.fulfill({
      status: 200,
      json: { posts: [{ id: 1, title: 'Hello World' }] },
    });
  });

  await page.goto('/feed');

  // First load fails
  await expect(page.getByText(/something went wrong/i)).toBeVisible();

  // Click retry — second call succeeds
  await page.getByRole('button', { name: 'Try again' }).click();

  await expect(page.getByText('Hello World')).toBeVisible();
  expect(callCount).toBe(2);
});

test('automatic retry with exponential backoff', async ({ page }) => {
  const callTimestamps: number[] = [];

  await page.route('**/api/status', (route) => {
    callTimestamps.push(Date.now());
    if (callTimestamps.length <= 3) {
      return route.fulfill({ status: 503 });
    }
    return route.fulfill({ status: 200, json: { status: 'ok' } });
  });

  await page.goto('/status');

  // Wait for the auto-retry to eventually succeed
  await expect(page.getByText('System operational')).toBeVisible({
    timeout: 30_000,
  });

  // Verify multiple retry attempts were made
  expect(callTimestamps.length).toBeGreaterThanOrEqual(4);

  // Verify backoff: gaps between retries should increase
  if (callTimestamps.length >= 3) {
    const gap1 = callTimestamps[1] - callTimestamps[0];
    const gap2 = callTimestamps[2] - callTimestamps[1];
    expect(gap2).toBeGreaterThanOrEqual(gap1);
  }
});

JavaScript

const { test, expect } = require('@playwright/test');

test('retry button recovers from a failed API call', async ({ page }) => {
  let callCount = 0;

  await page.route('**/api/feed', (route) => {
    callCount++;
    if (callCount === 1) {
      return route.fulfill({ status: 500 });
    }
    return route.fulfill({
      status: 200,
      json: { posts: [{ id: 1, title: 'Hello World' }] },
    });
  });

  await page.goto('/feed');

  await expect(page.getByText(/something went wrong/i)).toBeVisible();

  await page.getByRole('button', { name: 'Try again' }).click();

  await expect(page.getByText('Hello World')).toBeVisible();
  expect(callCount).toBe(2);
});

test('automatic retry with exponential backoff', async ({ page }) => {
  const callTimestamps = [];

  await page.route('**/api/status', (route) => {
    callTimestamps.push(Date.now());
    if (callTimestamps.length <= 3) {
      return route.fulfill({ status: 503 });
    }
    return route.fulfill({ status: 200, json: { status: 'ok' } });
  });

  await page.goto('/status');

  await expect(page.getByText('System operational')).toBeVisible({
    timeout: 30_000,
  });

  expect(callTimestamps.length).toBeGreaterThanOrEqual(4);
});

Browser Back/Forward Navigation

Use when: Testing that the application handles browser history navigation correctly — preserving state, URL updates, and content after going back or forward. Avoid when: The app is a single-page application that does not use the browser history API. Focus on client-side routing tests instead.

TypeScript

import { test, expect } from '@playwright/test';

test('browser back preserves navigation context', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('link', { name: 'Running Shoes' }).click();
  await page.waitForURL('**/products/running-shoes');

  // Go back
  await page.goBack();

  await expect(page).toHaveURL(/\/products$/);
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
});

test('browser forward returns to previous page', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('link', { name: 'Running Shoes' }).click();
  await page.waitForURL('**/products/running-shoes');

  await page.goBack();
  await page.goForward();

  await expect(page).toHaveURL(/\/products\/running-shoes/);
  await expect(page.getByRole('heading', { name: 'Running Shoes' })).toBeVisible();
});

test('form state after browser back', async ({ page }) => {
  await page.goto('/checkout');

  // Fill form on step 1
  await page.getByLabel('Address').fill('123 Main St');
  await page.getByRole('button', { name: 'Continue' }).click();

  // Arrived at step 2
  await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();

  // Go back to step 1
  await page.goBack();

  // Data should be preserved (depends on app implementation)
  await expect(page.getByLabel('Address')).toHaveValue('123 Main St');
});

test('back button after form submission does not re-submit', async ({ page }) => {
  await page.goto('/contact');

  await page.getByLabel('Name').fill('Jane');
  await page.getByLabel('Email').fill('jane@example.com');
  await page.getByLabel('Message').fill('Test');
  await page.getByRole('button', { name: 'Send' }).click();

  await expect(page.getByText('Message sent')).toBeVisible();

  // Going back should show the form, not re-submit
  await page.goBack();
  await expect(page.getByLabel('Name')).toBeVisible();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('browser back preserves navigation context', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('link', { name: 'Running Shoes' }).click();
  await page.waitForURL('**/products/running-shoes');

  await page.goBack();

  await expect(page).toHaveURL(/\/products$/);
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
});

test('browser forward returns to previous page', async ({ page }) => {
  await page.goto('/products');
  await page.getByRole('link', { name: 'Running Shoes' }).click();
  await page.waitForURL('**/products/running-shoes');

  await page.goBack();
  await page.goForward();

  await expect(page).toHaveURL(/\/products\/running-shoes/);
  await expect(page.getByRole('heading', { name: 'Running Shoes' })).toBeVisible();
});

test('form state after browser back', async ({ page }) => {
  await page.goto('/checkout');

  await page.getByLabel('Address').fill('123 Main St');
  await page.getByRole('button', { name: 'Continue' }).click();

  await expect(page.getByRole('heading', { name: 'Payment' })).toBeVisible();

  await page.goBack();
  await expect(page.getByLabel('Address')).toHaveValue('123 Main St');
});

test('back button after form submission does not re-submit', async ({ page }) => {
  await page.goto('/contact');

  await page.getByLabel('Name').fill('Jane');
  await page.getByLabel('Email').fill('jane@example.com');
  await page.getByLabel('Message').fill('Test');
  await page.getByRole('button', { name: 'Send' }).click();

  await expect(page.getByText('Message sent')).toBeVisible();

  await page.goBack();
  await expect(page.getByLabel('Name')).toBeVisible();
});

Concurrent User Actions

Use when: Testing that rapid user interactions do not cause race conditions — double-clicking submit, typing while data is loading, navigating during an async operation. Avoid when: The UI has no async operations that could conflict with user actions.

TypeScript

import { test, expect } from '@playwright/test';

test('double-click submit does not create duplicate entries', async ({ page }) => {
  const requests: string[] = [];

  await page.route('**/api/orders', (route) => {
    requests.push(route.request().method());
    return route.fulfill({
      status: 201,
      json: { id: 1, status: 'created' },
    });
  });

  await page.goto('/checkout');
  await page.getByLabel('Item').fill('Widget');

  const submitButton = page.getByRole('button', { name: 'Place order' });

  // Rapid double-click
  await submitButton.dblclick();

  // Wait for the result
  await expect(page.getByText('Order confirmed')).toBeVisible();

  // The app should prevent duplicate submissions
  // (button disabled after first click, or server deduplication)
  expect(requests.filter((m) => m === 'POST').length).toBeLessThanOrEqual(1);
});

test('typing during navigation does not crash', async ({ page }) => {
  await page.goto('/search');

  // Start typing and immediately navigate
  await page.getByLabel('Search').pressSequentially('test query', { delay: 30 });
  await page.getByRole('link', { name: 'Home' }).click();

  // Should arrive at home page without errors
  await page.waitForURL('**/');
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

test('rapid filter changes use latest value', async ({ page }) => {
  const requestUrls: string[] = [];

  await page.route('**/api/products*', (route) => {
    requestUrls.push(route.request().url());
    return route.fulfill({
      status: 200,
      json: { products: [{ name: 'Latest result' }] },
    });
  });

  await page.goto('/products');

  // Rapidly change the filter
  await page.getByLabel('Category').selectOption('electronics');
  await page.getByLabel('Category').selectOption('clothing');
  await page.getByLabel('Category').selectOption('books');

  // Wait for the final result
  await expect(page.getByText('Latest result')).toBeVisible();

  // The UI should show results for "books", not an earlier selection
  // Some apps debounce; some cancel in-flight requests
});

JavaScript

const { test, expect } = require('@playwright/test');

test('double-click submit does not create duplicate entries', async ({ page }) => {
  const requests = [];

  await page.route('**/api/orders', (route) => {
    requests.push(route.request().method());
    return route.fulfill({
      status: 201,
      json: { id: 1, status: 'created' },
    });
  });

  await page.goto('/checkout');
  await page.getByLabel('Item').fill('Widget');

  const submitButton = page.getByRole('button', { name: 'Place order' });
  await submitButton.dblclick();

  await expect(page.getByText('Order confirmed')).toBeVisible();

  expect(requests.filter((m) => m === 'POST').length).toBeLessThanOrEqual(1);
});

test('typing during navigation does not crash', async ({ page }) => {
  await page.goto('/search');

  await page.getByLabel('Search').pressSequentially('test query', { delay: 30 });
  await page.getByRole('link', { name: 'Home' }).click();

  await page.waitForURL('**/');
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

test('rapid filter changes use latest value', async ({ page }) => {
  const requestUrls = [];

  await page.route('**/api/products*', (route) => {
    requestUrls.push(route.request().url());
    return route.fulfill({
      status: 200,
      json: { products: [{ name: 'Latest result' }] },
    });
  });

  await page.goto('/products');

  await page.getByLabel('Category').selectOption('electronics');
  await page.getByLabel('Category').selectOption('clothing');
  await page.getByLabel('Category').selectOption('books');

  await expect(page.getByText('Latest result')).toBeVisible();
});

Graceful Degradation

Use when: Testing that the app continues to function when non-critical services fail (analytics, chat widget, recommendations). Avoid when: The failing service is critical to the core workflow.

TypeScript

import { test, expect } from '@playwright/test';

test('page works when analytics service fails', async ({ page }) => {
  // Block analytics and tracking scripts
  await page.route('**/analytics/**', (route) => route.abort());
  await page.route('**/tracking/**', (route) => route.abort());

  await page.goto('/products');

  // Core functionality still works
  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
  await page.getByRole('link', { name: 'Running Shoes' }).click();
  await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
});

test('page works when recommendation engine fails', async ({ page }) => {
  await page.route('**/api/recommendations', (route) =>
    route.fulfill({ status: 500 })
  );

  await page.goto('/products/running-shoes');

  // Main product content loads
  await expect(page.getByRole('heading', { name: 'Running Shoes' })).toBeVisible();
  await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();

  // Recommendations section shows fallback
  await expect(
    page.getByText(/recommendations unavailable|you may also like/i)
  ).toBeVisible();
});

test('page works when third-party chat widget fails to load', async ({ page }) => {
  // Block the chat widget script
  await page.route('**/chat-widget.js', (route) => route.abort());

  await page.goto('/support');

  // Core support page loads
  await expect(page.getByRole('heading', { name: 'Help Center' })).toBeVisible();

  // No console errors from the blocked widget should crash the page
  const errors: string[] = [];
  page.on('pageerror', (error) => errors.push(error.message));

  // Navigate around to ensure no cascade failures
  await page.getByRole('link', { name: 'FAQ' }).click();
  await expect(page.getByRole('heading', { name: 'FAQ' })).toBeVisible();
});

JavaScript

const { test, expect } = require('@playwright/test');

test('page works when analytics service fails', async ({ page }) => {
  await page.route('**/analytics/**', (route) => route.abort());
  await page.route('**/tracking/**', (route) => route.abort());

  await page.goto('/products');

  await expect(page.getByRole('heading', { name: 'Products' })).toBeVisible();
  await page.getByRole('link', { name: 'Running Shoes' }).click();
  await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
});

test('page works when recommendation engine fails', async ({ page }) => {
  await page.route('**/api/recommendations', (route) =>
    route.fulfill({ status: 500 })
  );

  await page.goto('/products/running-shoes');

  await expect(page.getByRole('heading', { name: 'Running Shoes' })).toBeVisible();
  await expect(page.getByRole('button', { name: 'Add to cart' })).toBeEnabled();
});

test('page works when third-party chat widget fails to load', async ({ page }) => {
  await page.route('**/chat-widget.js', (route) => route.abort());

  await page.goto('/support');

  await expect(page.getByRole('heading', { name: 'Help Center' })).toBeVisible();

  await page.getByRole('link', { name: 'FAQ' }).click();
  await expect(page.getByRole('heading', { name: 'FAQ' })).toBeVisible();
});

Decision Guide

Scenario Approach Key API
404 page Navigate to non-existent URL, assert error page page.goto('/nonexistent')
500 server error Mock route with status: 500 page.route(url, route => route.fulfill({ status: 500 }))
Network failure Abort the route route.abort('connectionfailed')
Offline mode Toggle offline on the browser context page.context().setOffline(true)
Slow response Delay route fulfillment with a Promise await new Promise(r => setTimeout(r, delay)) in route handler
Empty state Mock API to return empty array route.fulfill({ json: [] })
Boundary values Fill inputs with min/max/special values locator.fill('A'.repeat(255))
Loading skeleton Delay route, assert skeleton visible, release, assert content Promise-based route handler
Retry behavior Track route call count, fail first N, succeed after Counter in route handler
Browser history Use page.goBack() and page.goForward() Assert URL and content after navigation
Double submit dblclick() on submit, verify single POST Track requests in route handler
Third-party failure Abort non-critical routes, verify core works route.abort() on optional services
Console error monitoring Listen for pageerror event page.on('pageerror', handler)

Anti-Patterns

Don't Do This Problem Do This Instead
Only testing the happy path Real users hit errors. Errors in production are more expensive than test time. Add error/edge case tests for every feature
page.route('**/*', route => route.abort()) Blocks ALL requests including the page itself Target specific URLs: page.route('**/api/specific', ...)
Using page.waitForTimeout() to simulate slow loading Arbitrary, flaky, slows tests Use promise-based route handlers to control timing precisely
Hardcoding error messages in assertions Messages change. Tests break on copy edits. Use regex or partial matches: getByText(/error/i)
Testing every error code in one mega-test Hard to debug, slow, first failure hides the rest One test per error scenario, or use test.describe groups
Skipping empty state tests Empty states are the first thing new users see; often broken Always test 0-item state alongside 1-item and N-item
Testing offline by disconnecting real network Flaky, affects parallel tests, CI may not support it Use context.setOffline(true) — deterministic and scoped
Not cleaning up route mocks between tests Routes persist on the page. Test B inherits test A's mocks. Each test sets its own routes; Playwright resets between tests by default
Asserting on exact error strings from the server Couples tests to backend implementation details Assert the UI message, not the raw API response body
Using try/catch to "handle" expected errors Swallows real bugs; test passes when it shouldn't Let errors propagate; use route mocking to control the response

Troubleshooting

Route handler is not intercepting requests

Cause: The URL pattern does not match, or the route was registered after the navigation that triggers the request.

// Always register routes BEFORE navigating
await page.route('**/api/data', (route) => route.fulfill({ status: 500 }));
await page.goto('/dashboard'); // route is active before the page loads

// Debug: log all requests to find the actual URL pattern
page.on('request', (req) => console.log(req.url()));

context.setOffline(true) does not affect Service Worker

Cause: Service Workers have their own network handling. setOffline simulates offline at the browser level, but a Service Worker may serve cached responses.

// Unregister service workers first
await page.evaluate(async () => {
  const registrations = await navigator.serviceWorker.getRegistrations();
  for (const registration of registrations) {
    await registration.unregister();
  }
});
await page.context().setOffline(true);

Delayed route handler causes test timeout

Cause: The promise in the route handler never resolves, or the delay exceeds the test timeout.

// Always ensure the delay is less than the assertion timeout
await page.route('**/api/slow', async (route) => {
  await new Promise((resolve) => setTimeout(resolve, 5_000)); // 5s delay
  await route.fulfill({ status: 200, json: {} });
});

// Increase assertion timeout to account for the artificial delay
await expect(page.getByText('Data loaded')).toBeVisible({ timeout: 10_000 });

goBack() does not navigate

Cause: There is no history entry to go back to. goBack() requires at least one previous navigation.

// Ensure there is history before calling goBack
await page.goto('/page-a');
await page.goto('/page-b');
await page.goBack(); // goes to /page-a

// If using SPA client-side routing, goBack may not work if the router
// does not push to browser history. Use the app's own back button instead.
await page.getByRole('button', { name: 'Back' }).click();