Blog Post
4 min read

Testing the Frontend Like a Service

A frontend is a service with contracts, SLAs, and failure modes.

Published on March 26, 2026

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();
  });
});