A frontend is a service with contracts, SLAs, and failure modes.
Treat it that way. Test it like a service: define contracts, verify observability, and practice failure.
Contract Tests: API Shapes and Responses
A contract test validates that the API returns what the client expects.
Mock Servers and Typed Clients
Use a mock server that matches production API schema:
import { setupServer } from 'msw';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice' },
{ id: '2', name: 'Bob' }
]);
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
test('fetches users and renders list', async () => {
render(<UserList />);
const items = await screen.findAllByRole('listitem');
expect(items).toHaveLength(2);
expect(items[0]).toHaveTextContent('Alice');
});
Golden Tests with Real Responses
Snapshot the real API response. If the API changes, the test fails:
test("API response shape matches contract", async () => {
const response = await fetch("/api/users");
const data = await response.json();
expect(data).toMatchSnapshot(); // Golden test
expect(data[0]).toHaveProperty("id");
expect(data[0]).toHaveProperty("name");
});
If you intentionally change the API, update the snapshot:
npm test -- --updateSnapshot
Observability: Tracing and Logging
Journey IDs
Assign each user session a unique ID. Trace it through frontend, backend, and infrastructure:
// frontend/lib/tracing.ts
const journeyId = localStorage.getItem("journeyId") || crypto.randomUUID();
localStorage.setItem("journeyId", journeyId);
export function logEvent(name: string, data?: any) {
analytics.track(name, {
journeyId,
...data,
});
}
Server adds the journeyId to logs:
// backend
app.use((req, res, next) => {
const journeyId = req.headers["x-journey-id"];
req.journeyId = journeyId;
logger.info({ journeyId, path: req.path });
next();
});
Now you can trace a user's full journey from signup to purchase.
Trace Slow Interactions
Log INP violations with component context:
import { onINP } from "web-vitals";
onINP(({ value, attribution }) => {
if (value > 200) {
analytics.track("inp_violation", {
value,
eventTarget: attribution.eventTarget?.id,
eventType: attribution.eventType,
component: getCurrentComponentName(), // You need to track this
journeyId,
});
}
});
Now the team knows: "The product filter dropdown has a consistent INP issue; the owner is Alice."
Alert on Error Rates
Track JavaScript errors and silent failures:
window.addEventListener("error", (event) => {
analytics.track("js_error", {
message: event.message,
stack: event.filename + ":" + event.lineno,
journeyId,
});
});
// Silent failures: failed fetch without user feedback
const originalFetch = window.fetch;
window.fetch = function (...args) {
return originalFetch.apply(this, args).catch((err) => {
analytics.track("fetch_error", {
url: args[0],
error: err.message,
journeyId,
});
throw err;
});
};
Alert if error rate > 0.1%:
alert:
name: High Frontend Error Rate
condition: error_rate > 0.001
action: Page #frontend-alerts, block deployment
Failure Drills: Practice Recovery
Test your app under realistic failure conditions.
Offline and Slow Networks
Simulate network conditions in tests:
test('shows offline message when network is unavailable', async () => {
// Simulate offline
vi.stubGlobal('navigator', { onLine: false });
render(<App />);
expect(screen.getByText(/You are offline/)).toBeInTheDocument();
});
test('retries fetch when network is restored', async () => {
// Start offline
vi.stubGlobal('navigator', { onLine: false });
render(<App />);
// Go online
vi.stubGlobal('navigator', { onLine: true });
window.dispatchEvent(new Event('online'));
await waitFor(() => {
expect(screen.queryByText(/You are offline/)).not.toBeInTheDocument();
});
});
Token Expiration
Test that the app handles expired auth tokens:
test('refreshes token when expired', async () => {
const server = setupServer(
http.get('/api/user', ({ request }) => {
const auth = request.headers.get('Authorization');
if (auth === 'Bearer old-token') {
return HttpResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return HttpResponse.json({ id: '1', name: 'Alice' });
})
);
server.listen();
// Simulate expired token
localStorage.setItem('token', 'old-token');
render(<App />);
// App should refetch with new token
await waitFor(() => {
expect(localStorage.getItem('token')).toBe('new-token');
});
server.close();
});
Permission Changes
Test that the app handles permission revocation (user downgraded, admin access removed):
test('redirects to dashboard when user loses admin access', async () => {
// Start as admin
localStorage.setItem('user', JSON.stringify({ role: 'admin' }));
render(<AdminPanel />);
expect(screen.getByText(/Admin Panel/)).toBeInTheDocument();
// Permission revoked (e.g., in another tab)
localStorage.setItem('user', JSON.stringify({ role: 'user' }));
window.dispatchEvent(new StorageEvent('storage', {
key: 'user',
newValue: JSON.stringify({ role: 'user' })
}));
// Should redirect
await waitFor(() => {
expect(window.location.pathname).toBe('/dashboard');
});
});
Feature Flag Rollbacks
Test that disabling a feature flag removes it gracefully:
test('hides new feature when flag is disabled', async () => {
// Enable feature flag
server.use(
http.get('/api/flags', () => {
return HttpResponse.json({ newCheckout: true });
})
);
const { rerender } = render(<App />);
expect(screen.getByText(/New Checkout/)).toBeInTheDocument();
// Disable feature flag
server.use(
http.get('/api/flags', () => {
return HttpResponse.json({ newCheckout: false });
})
);
// Refetch flags
fireEvent(window, new Event('focus'));
rerender(<App />);
// Feature should be hidden
await waitFor(() => {
expect(screen.queryByText(/New Checkout/)).not.toBeInTheDocument();
});
});