Testing error pages: Playwright best practices explained
Error pages are often an afterthought in test coverage, yet they're where users form their first impression of how your team handles failure. When a 404, 500, or timeout occurs, a poorly tested error page can confuse users, leak sensitive information, or leave them stranded without a path forward. In this guide, we'll walk through testing error pages with Playwright—covering how to force error responses, validate helpful messages, verify retry mechanisms, and ensure no sensitive data escapes into error output.
Why error pages deserve test coverage
Error pages exist at the intersection of user experience and system reliability. Unlike happy-path flows that execute dozens of times per day in production, error states might only surface when something genuinely breaks. This means:
- Error pages often receive minimal testing attention, making them prime candidates for bugs.
- Users encountering errors are already frustrated; confusing copy or missing recovery options compound that frustration.
- Sensitive data (stack traces, database details, API keys) can leak through error output if not carefully controlled.
- The difference between a 404 and a 500 matters to search engines and monitoring systems.
Playwright gives us tools to simulate these failure scenarios reliably, without needing to break your actual application.
Force 404 and 500 responses
The most direct way to test error pages is to trigger them intentionally. We have two main approaches: request interception (to mock server responses) and direct navigation to error-triggering URLs.
Using route handlers to mock error responses
Playwright's page.route() method lets us intercept requests and respond with custom status codes. This is powerful because we control exactly when and which requests fail.
import { test, expect } from '@playwright/test';
test('should display helpful 404 page', async ({ page }) => {
// Intercept all requests to product API and respond with 404
await page.route('**/api/products/**', route => {
route.abort('blockedbyresponse');
});
// Alternatively, respond with a 404 status
await page.route('**/api/products/**', route => {
route.respond({
status: 404,
contentType: 'application/json',
body: JSON.stringify({ error: 'Product not found' })
});
});
await page.goto('/products/nonexistent-id');
// Verify the error page renders
await expect(page.locator('text=Product not found')).toBeVisible();
await expect(page.locator('[data-testid="back-to-catalog"]')).toBeVisible();
});
test('should display helpful 500 page', async ({ page }) => {
// Simulate a server error
await page.route('**/api/checkout', route => {
route.respond({
status: 500,
contentType: 'application/json',
body: JSON.stringify({
error: 'Server temporarily unavailable. Please try again.'
})
});
});
await page.goto('/checkout');
await page.locator('button:has-text("Place Order")').click();
// Verify graceful error handling
await expect(page.locator('text=Server temporarily unavailable')).toBeVisible();
await expect(page.locator('[data-testid="retry-button"]')).toBeVisible();
});Direct URL navigation for static error pages
If your application serves static error pages at specific routes, navigate directly:
test('static error page routes', async ({ page }) => {
await page.goto('/404');
await expect(page.locator('h1')).toContainText('Page not found');
await page.goto('/500');
await expect(page.locator('h1')).toContainText('Something went wrong');
});Real-world scenario: An e-commerce site removes a product. You want to verify that users landing on a cached or bookmarked link see a clear 404 page, not a blank screen or error logs.
Assert helpful messages
Error messages should guide users toward resolution. Test that your error pages provide clear context and actionable next steps.
test('error message is user-friendly and specific', async ({ page }) => {
await page.route('**/api/payment', route => {
route.respond({
status: 402,
contentType: 'application/json',
body: JSON.stringify({
error: 'Card declined',
errorCode: 'card_declined_fraud_check'
})
});
});
await page.goto('/payment');
await page.locator('button:has-text("Pay")').click();
// Assert the message is user-friendly, not a code
const errorMessage = page.locator('[data-testid="error-message"]');
await expect(errorMessage).toContainText('Your payment was declined');
await expect(errorMessage).not.toContainText('card_declined_fraud_check');
// Assert there's guidance on next steps
await expect(page.locator('text=Try another card')).toBeVisible();
await expect(page.locator('text=Contact support')).toBeVisible();
});
test('error message respects locale', async ({ page }) => {
// Set language preference
await page.context().addCookies([{
name: 'language',
value: 'es',
url: 'http://localhost:3000'
}]);
await page.route('**/api/data', route => {
route.respond({ status: 500 });
});
await page.goto('/dashboard');
// Verify Spanish copy
await expect(page.locator('text=Algo salió mal')).toBeVisible();
});When testing messages, check both the text content and tone. A message like "An error occurred" is less useful than "Your file couldn't be saved. Try again or contact support."
Check retry buttons
Users encountering transient errors (timeouts, temporary service outages) should have a clear way to retry. Test that retry mechanisms work correctly.
test('retry button successfully recovers from transient error', async ({ page }) => {
let attemptCount = 0;
await page.route('**/api/data', route => {
attemptCount++;
if (attemptCount === 1) {
// First request fails
route.respond({ status: 503, body: 'Service unavailable' });
} else {
// Subsequent requests succeed
route.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ data: [{ id: 1, name: 'Item' }] })
});
}
});
await page.goto('/dashboard');
// Verify error state
await expect(page.locator('text=Service temporarily unavailable')).toBeVisible();
const retryButton = page.locator('[data-testid="retry-button"]');
await expect(retryButton).toBeVisible();
// Click retry
await retryButton.click();
// Verify recovery
await expect(page.locator('text=Item')).toBeVisible();
await expect(page.locator('text=Service temporarily unavailable')).not.toBeVisible();
});
test('retry button has disabled state during loading', async ({ page }) => {
await page.route('**/api/data', async route => {
// Slow response
await new Promise(resolve => setTimeout(resolve, 2000));
route.respond({ status: 200, body: '{}' });
});
await page.goto('/dashboard');
const retryButton = page.locator('[data-testid="retry-button"]');
await retryButton.click();
// Button should be disabled while loading
await expect(retryButton).toBeDisabled();
await expect(retryButton).toContainText('Retrying');
// Button re-enables after response
await expect(retryButton).toBeEnabled();
});Transient failures happen in production. A retry button backed by proper exponential backoff logic can resolve many issues without involving customer support.
Verify no sensitive data leaks
This is security-critical. Error pages must never expose stack traces, database query details, API keys, internal URLs, or other sensitive information to users.
test('error page does not leak stack traces', async ({ page }) => {
await page.route('**/api/admin/config', route => {
route.respond({
status: 500,
contentType: 'application/json',
body: JSON.stringify({
error: 'Internal Server Error',
stack: 'Error: Connection refused\n at Database.query\n at processRequest'
})
});
});
await page.goto('/settings');
// Verify user-facing message is generic
const errorText = await page.locator('[data-testid="error-message"]').textContent();
expect(errorText).toContainText('Something went wrong');
// Verify sensitive data is NOT visible
expect(errorText).not.toContain('Connection refused');
expect(errorText).not.toContain('at Database.query');
expect(errorText).not.toContain('processRequest');
});
test('error page does not expose database details', async ({ page }) => {
await page.route('**/api/search', route => {
route.respond({
status: 400,
body: 'SQL syntax error near column users.password'
});
});
await page.goto('/search');
await page.locator('input[type="search"]').fill('test');
await page.locator('button:has-text("Search")').click();
const errorElement = page.locator('[data-testid="error-message"]');
const errorText = await errorElement.textContent();
expect(errorText).not.toContain('SQL');
expect(errorText).not.toContain('users.password');
expect(errorText).not.toContain('syntax error');
});
test('error page does not expose internal URLs or IPs', async ({ page }) => {
await page.route('**/api/webhook', route => {
route.respond({
status: 502,
body: JSON.stringify({
error: 'Gateway error',
upstream: 'http://internal-db.local:5432'
})
});
});
await page.goto('/integrations');
const errorText = await page.locator('[data-testid="error-message"]').textContent();
expect(errorText).not.toContain('internal-db');
expect(errorText).not.toContain('5432');
});
test('error page respects http-only cookies during errors', async ({ page, context }) => {
await context.addCookies([{
name: 'authToken',
value: 'secret-jwt-token',
url: 'http://localhost:3000',
httpOnly: true
}]);
await page.route('**/api/sensitive', route => {
route.respond({ status: 403 });
});
await page.goto('/dashboard');
// Verify JavaScript cannot read the token
const cookieValue = await page.evaluate(() => {
return document.cookie;
});
expect(cookieValue).not.toContain('secret-jwt-token');
});A common attack vector is extracting sensitive information from error pages. Always ensure error responses are sanitized before reaching the browser. Log full details server-side for debugging, but return only safe, generic messages to the client.
Common mistakes
- Testing only the happy path: Many teams skip error page tests entirely. Errors are stateful and often reveal bugs in recovery flows that don't exist in normal operation.
- Hardcoding error scenarios: If your test only checks one specific error code, you miss variations. Use parameterized tests to cover 400, 401, 403, 404, 500, 502, 503 and timeout scenarios.
- Not testing retry exhaustion: If a user retries multiple times and errors persist, they should see a "contact support" link, not infinite retry buttons. Test that path too.
- Ignoring locale and accessibility: Error messages should be translated and screen-reader friendly. A generic error in the user's language is better than English jargon.
- Mocking errors server-side instead of with Playwright: Using feature flags or hardcoded logic to trigger errors during test is fragile. Use Playwright's route interception so your actual error handling code runs.
- Forgetting to clean up routes: Route handlers persist across tests. Always unregister them or create a fresh context for each test to avoid cross-test pollution.
Key takeaways
- Use
page.route()to intercept and mock error responses, triggering 404, 500, and other failure states reliably without breaking your application. - Assert that error messages are user-friendly, locale-aware, and actionable—never technical jargon or error codes.
- Test retry buttons for both success (recovery after transient errors) and failure (exhaustion with fallback to support).
- Verify that error pages never leak stack traces, database details, internal IPs, or sensitive configuration to the browser.
- Cover a variety of HTTP status codes (400, 401, 403, 404, 500, 502, 503) and timeout scenarios, not just one hardcoded error.
- Treat error page tests as security and UX tests, not just functional checks—they directly impact user trust when things break.






Comments
Loading comments…