Happy path testing is easy. Any junior can verify that products load when the API returns 200. The real skill is testing what happens when things go wrong. Server crashes. Network drops. Timeouts. Rate limiting. Your app should handle all of it gracefully, and your tests should verify that it does.
import { test, expect } from '@playwright/test';
test('handle 500 Internal Server Error', async ({ page }) => {
await page.route('**/api/products', async (route) => {
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({
error: 'Internal Server Error',
message: 'Database connection failed',
}),
});
});
await page.goto('/shopping');
// UI should show error state, NOT crash
await expect(page.getByText(/something went wrong/i)).toBeVisible();
await expect(page.getByRole('button', { name: /retry|try again/i })).toBeVisible();
});
test('handle 503 Service Unavailable', async ({ page }) => {
await page.route('**/api/products', async (route) => {
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: 'Service temporarily unavailable' }),
});
});
await page.goto('/shopping');
await expect(page.getByText(/service.*unavailable|maintenance/i)).toBeVisible();
});A network failure is different from a server error. With a server error, the request reaches the server and it responds with an error. With a network failure, the request never reaches the server at all. The connection drops.
test('handle complete network failure', async ({ page }) => {
await page.route('**/api/products', async (route) => {
// abort() simulates network failure -- connection refused
await route.abort('connectionrefused');
});
await page.goto('/shopping');
await expect(page.getByText(/network error|unable to connect/i)).toBeVisible();
});
test('handle DNS failure', async ({ page }) => {
await page.route('**/api/**', async (route) => {
await route.abort('namenotresolved');
});
await page.goto('/shopping');
await expect(page.getByText(/could not reach server/i)).toBeVisible();
});| Abort Reason | Simulates | Real World Scenario |
|---|---|---|
| connectionrefused | Server not accepting connections | Server is down |
| connectionreset | Connection dropped mid-transfer | Unstable network |
| namenotresolved | DNS lookup failed | DNS server down or wrong URL |
| timedout | Request timed out | Server too slow to respond |
| internetdisconnected | No internet at all | WiFi dropped |
test('show timeout message when API takes too long', async ({ page }) => {
await page.route('**/api/products', async (route) => {
// Simulate a very slow response -- 30 seconds
await new Promise((resolve) => setTimeout(resolve, 30000));
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([]),
});
});
await page.goto('/shopping');
// If the app has a client-side timeout (e.g., 10 seconds),
// it should show a timeout message
await expect(page.getByText(/timeout|taking too long/i)).toBeVisible({
timeout: 15000,
});
});
// Cleaner approach: just abort with timedout
test('abort with timeout reason', async ({ page }) => {
await page.route('**/api/products', async (route) => {
await route.abort('timedout');
});
await page.goto('/shopping');
await expect(page.getByText(/timeout|try again/i)).toBeVisible();
});test('app retries on failure, succeeds on second attempt', async ({ page }) => {
let callCount = 0;
await page.route('**/api/products', async (route) => {
callCount++;
if (callCount === 1) {
// First call fails
await route.fulfill({ status: 500, body: 'Server Error' });
} else {
// Second call succeeds
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Widget' }]),
});
}
});
await page.goto('/shopping');
// If the app has retry logic, it should eventually show data
await expect(page.getByText('Widget')).toBeVisible({ timeout: 10000 });
expect(callCount).toBe(2); // Verify retry happened
});Testing retry logic is a great way to verify resilience. If your app does not retry and you think it should, file a bug. This is exactly the kind of thing QA catches with mocking that you would never catch with happy-path testing.
Key Point: Test the unhappy paths. route.fulfill() with error status codes for server errors. route.abort() for network failures. Both are critical for robust apps.