Frontend Testing Architecture
Testing strategy is an architectural decision. Too many unit tests and you test implementation details. Too many E2E tests and your suite is slow and flaky. Senior engineers design testing pyramids that maximize confidence per second of CI time.
The Testing Trophy
The modern frontend test distribution looks less like a pyramid and more like a trophy:
โญโโโโโโโฎ
โ E2E โ โ Few critical user journeys
โฐโโโฌโโโโฏ
โญโโโโโดโโโโโโฎ
โIntegrationโ โ Most tests live here
โฐโโโโโฌโโโโโโฏ
โญโโโโโโดโโโโโโโฎ
โ Component โ โ Render + interact + assert
โฐโโโโโโฌโโโโโโโฏ
โญโโโโดโโโโฎ
โStatic โ โ TypeScript + ESLint
โฐโโโโโโโโฏStatic Analysis (Foundation)
TypeScript and ESLint catch entire categories of bugs before any test runs:
function calculateTotal(items: CartItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}Component Tests
Test components in isolation with a real DOM but mocked dependencies:
import { render, screen, fireEvent } from '@testing-library/react';
test('increments counter on click', () => {
render(<Counter initial={0} />);
expect(screen.getByRole('status')).toHaveTextContent('0');
fireEvent.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByRole('status')).toHaveTextContent('1');
});Key principle: Test behavior, not implementation. Query by role/text, not by test IDs or class names.
Integration Tests
Test how multiple components work together, with real state and routing:
test('search flow: type query โ see results โ click result', async () => {
server.use(
http.get('/api/search', ({ request }) => {
const url = new URL(request.url);
return HttpResponse.json([
{ id: 1, title: 'React Hooks' },
]);
})
);
render(<App />, { wrapper: TestProviders });
await userEvent.type(screen.getByRole('searchbox'), 'react');
await screen.findByText('React Hooks');
await userEvent.click(screen.getByText('React Hooks'));
expect(screen.getByRole('heading')).toHaveTextContent('React Hooks');
});E2E Tests
Test critical user journeys through the full stack:
test('checkout flow', async ({ page }) => {
await page.goto('/products');
await page.getByRole('button', { name: 'Add to cart' }).first().click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});What to Test at Each Level
| Level | What | Example |
|---|---|---|
| Static | Type errors, lint rules | CartItem missing price field |
| Component | Render output, user interactions | Button shows loading state on click |
| Integration | Feature flows, state + UI | Search results update URL and display |
| E2E | Critical paths, cross-page flows | Sign up โ Add to cart โ Checkout |
Testing Patterns
MSW for API Mocking
Mock Service Worker intercepts at the network level โ your code uses real fetch:
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users', () => {
return HttpResponse.json([
{ id: 1, name: 'Alex' },
{ id: 2, name: 'Sam' },
]);
}),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Testing Custom Hooks
import { renderHook, act } from '@testing-library/react';
test('useCounter increments', () => {
const { result } = renderHook(() => useCounter(0));
expect(result.current.count).toBe(0);
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});Snapshot Testing (Use Sparingly)
Snapshots are useful for detecting unintended changes, but they're noisy and often blindly updated:
test('renders correctly', () => {
const { container } = render(<Badge variant="success">Active</Badge>);
expect(container).toMatchInlineSnapshot(`
<div>
<span class="badge badge-success">Active</span>
</div>
`);
});Prefer inline snapshots over file snapshots โ they're easier to review in diffs.
Visual Regression Testing
Tools like Chromatic or Percy capture screenshots and diff against baselines:
test('button variants', async ({ page }) => {
await page.goto('/storybook/iframe.html?id=button--all-variants');
await expect(page).toHaveScreenshot('button-variants.png');
});Testing Accessibility
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('form is accessible', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});Test Organization
src/
โโโ components/
โ โโโ Button/
โ โ โโโ Button.tsx
โ โ โโโ Button.test.tsx โ Component test (co-located)
โ โ โโโ Button.stories.tsx โ Storybook (visual documentation)
โโโ hooks/
โ โโโ useAuth.ts
โ โโโ useAuth.test.ts
โโโ __tests__/
โ โโโ integration/
โ โโโ auth-flow.test.tsx โ Integration tests
โ โโโ search-flow.test.tsx
โโโ e2e/
โโโ checkout.spec.ts โ E2E tests (separate folder)
โโโ auth.spec.tsCI Pipeline
Static Analysis โ Component Tests โ Integration Tests โ E2E Tests โ Visual Regression
(30s) (1-2 min) (2-3 min) (3-5 min) (2-3 min)Fail fast: static analysis and component tests catch 80% of issues in under 2 minutes.
Interview Signal
Senior candidates demonstrate:
- Testing trophy โ Integration tests over unit tests, strategic E2E for critical paths
- Behavior testing โ Query by role/text, assert on user-visible outcomes
- MSW โ Network-level mocking that doesn't couple tests to implementation
- Confidence per minute โ Fast feedback loops, CI pipeline ordering, parallelization
- Accessibility testing โ Automated axe checks + manual testing strategy