mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-12 17:22:38 +02:00
1137 lines
38 KiB
Markdown
Executable file
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
|