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

1137 lines
38 KiB
Markdown
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](assertions-and-waiting.md), [core/network-mocking.md](network-mocking.md) for route interception
## Quick Reference
```typescript
// 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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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**
```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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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.
```typescript
// 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();
```
## Related
- [core/assertions-and-waiting.md](assertions-and-waiting.md) -- assertion strategies for error states
- [core/network-mocking.md](network-mocking.md) -- detailed network interception and mocking patterns
- [core/forms-and-validation.md](forms-and-validation.md) -- form validation error testing
- [core/flaky-tests.md](flaky-tests.md) -- fixing timing issues in error/edge case tests
- [core/service-workers-and-pwa.md](service-workers-and-pwa.md) -- offline-first and PWA testing patterns
- [core/multi-context-and-popups.md](multi-context-and-popups.md) -- testing concurrent browser contexts