Testing Strategy for Frontend Architecture
The goal is not 100% coverage. It is shipping without fear. A smart test strategy costs a fraction of a full-coverage suite but catches 90% of the regressions that matter.
Article focus
3 testing layers
Unit → Integration → E2E
Key takeaways
- Integration tests give the most confidence per line of test code. Focus there.
- Mock at your boundaries, not inside your components. Mock the API, test the UI.
- E2E tests are for critical paths only. A dozen good E2E tests beat a hundred flaky ones.
Why do most test suites feel like a tax?
Because they test implementation details instead of behavior. When you refactor, the tests break even though the feature still works.
A test suite should be an asset, not a liability. It should give you the confidence to refactor aggressively and ship frequently. But most test suites do the opposite - they slow down every change because they are coupled to how the code is written, not what it does.
The root cause is usually the same: testing at the wrong layer. A unit test that checks the internal state of a component after a click is fragile. An integration test that checks "when I click this button, this text appears" is robust. Both test the same behavior, but one survives refactors and the other does not.
The fix is a layered strategy where each layer has a clear purpose and a clear budget. This post is a deep-dive on one of the five pillars from the Frontend Architecture framework.
Testing Trophy Visualizer
Click each layer to see what it covers, when to use it, and a real example.
Integration Tests (60%)
Feature workflows: render, interact, assert. Best confidence per line of test code.
render(<UserList />) → wait for data → click edit → assert updateThe testing trophy - not a pyramid
The trophy model by Kent C. Dodds centers on integration tests, with fewer unit and E2E tests. Integration tests give the best confidence-to-effort ratio.
The testing pyramid (unit-heavy, integration-medium, E2E-light) is widely taught but rarely matches reality. Unit tests are fast but catch few real bugs. E2E tests catch real bugs but are slow and flaky. The sweet spot is integration tests - they test real workflows, use real components, and give high confidence at reasonable speed.
My typical distribution looks like:
This is not a rigid rule. Some projects need more E2E (payment flows). Some need more unit tests (data-heavy computation). But for most UI-heavy applications, the trophy model is the right starting point.
- ~20% Unit tests - pure logic: utilities, helpers, selectors, formatting functions. Fast, reliable, but low confidence per test.
- ~60% Integration tests - feature workflows: render a page, interact with it, assert the outcome. High confidence, reasonable speed.
- ~20% E2E tests - critical paths: auth, checkout, data export. Test the real system end to end. Slow but highest confidence.
Integration tests - where the real value lives
Render a page or feature, mock the API at the network boundary, interact with the UI, and assert on what the user sees.
An integration test for a user list page should: render the page, wait for data to load, verify that user names appear, click "Edit" on a user, fill in the form, submit, and verify the list updates. This one test covers data fetching, loading states, empty states, form interaction, and optimistic updates.
It does this by mocking at the network layer (MSW or similar), not by mocking individual components or hooks. The component tree is real. The rendering is real. The only thing fake is the API.
typescript
// Integration test with Testing Library + MSW
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
test('edits a user name and shows the update', async () => {
render(<UserListPage />);
// Wait for data to load
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument();
});
// Click edit and update the name
await userEvent.click(screen.getByRole('button', { name: /edit alice/i }));
await userEvent.clear(screen.getByLabelText(/name/i));
await userEvent.type(screen.getByLabelText(/name/i), 'Alice Smith');
await userEvent.click(screen.getByRole('button', { name: /save/i }));
// Verify the UI updated
await waitFor(() => {
expect(screen.getByText('Alice Smith')).toBeInTheDocument();
});
});Unit tests - fast feedback for the brainy parts
Unit test pure logic: utility functions, selectors, validation logic, data transformation. Do not unit test components.
Unit tests shine where there is real logic to verify. A function that formats currency, validates an email, or sorts a list - these are perfect unit test candidates. They are fast, they are deterministic, and they document the expected behavior of the function.
Components are not good unit test targets. A unit test that renders a button and checks its text is testing that JSX works, which is not valuable. A unit test that checks internal state after a click is testing implementation details, which is fragile.
If a component has complex logic, extract that logic into a pure function or a custom hook, and unit test that. The component itself gets tested through integration tests.
E2E tests - the critical path insurance
Write E2E tests only for paths where a failure means a customer-facing problem. Auth, checkout, data export. Keep the suite small.
E2E tests with Playwright or Cypress are powerful but expensive. They require a running server, a database, and often third-party services. They are slow. They are flaky (network hiccups, timing issues, element locators that break with small DOM changes).
I limit E2E to the paths that directly impact revenue or trust. A broken checkout loses money. A broken export loses data. A broken avatar upload is annoying but not critical.
My rule: if a failure would not wake me up at 2 AM, it does not need an E2E test. An integration test is sufficient.
typescript
// Playwright E2E - only for critical paths
test('complete checkout flow', async ({ page }) => {
await page.goto('/pricing');
await page.click('text=Start Free Trial');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="card"]', '4242 4242 4242 4242');
await page.click('text=Subscribe');
await expect(page.locator('text=Welcome to Pro')).toBeVisible();
});What to mock and what not to mock
Mock at network boundaries (API calls). Do not mock components, hooks, or internal modules. Use real rendering and real user interactions.
This keeps your tests honest. When a component breaks because a sibling changed its internal behavior, your test should break too. That is a signal, not a failure.
- Mock: API responses (MSW), browser APIs (localStorage, scroll), date/time (for snapshots), third-party SDKs (analytics, chat).
- Do NOT mock: React components (render them real), hooks (test them through components), internal utility modules (test them directly via unit tests), router (render inside a MemoryRouter).
- The principle: mock what crosses the boundary of your system. Everything inside the boundary should be real.
A starting point for adding tests to an untested project
Start with one integration test for the most important user flow. Add contract tests for the most critical API endpoints. Expand from there.
If your project has zero tests, do not try to write 200 tests in a weekend. You will burn out and the suite will be low quality. Instead:
This approach builds a test suite organically. After three months, you will have coverage where it matters most, and the suite will be high quality because each test was written with care. For more on contract tests in particular, see the API Contracts deep-dive.
- Week 1 - Write one integration test for the most important user flow (login, search, checkout - whatever matters most). Get the testing infrastructure right.
- Week 2 - Add contract tests for the three most critical API endpoints. These catch backend drift.
- Week 3 - Write one E2E test for the critical path. Run it in CI.
- Week 4 and beyond - Add integration tests as you fix bugs. Every bug fix gets a test that covers the scenario.
Need help implementing this?
I build these systems for a living - let's work on yours.
Frontend Architecture & System Design
Structure so teams can ship. Clear boundaries, state strategy, and contracts. New features land cleanly; refactors stay low-risk.
Frontend Development
Production UIs in React, Next.js, or Vue- third-party and payment integrations included. Built for real traffic and maintained in production.
Your turn
- >Did this help you ship something?
- >Which part clicked the most for you?
- >Applying this at work? Share your experience.
Recommended blogs
Continue reading

How React Knows What to Re-render: The Reconciliation & Diffing Algorithm
Learn React's reconciliation algorithm: virtual DOM diffing, key prop rules, render vs commit phases, React 18 batching, and concurrent rendering - from first principles to production patterns.
Photo by Joshua Plattner:
Read article
JavaScript Closures Explained: Why Your Functions Remember Everything
Learn JavaScript closures with interactive demos. Covers lexical scope, the var vs let loop bug, stale React hooks, memory leak patterns, and closure interview questions.
Reference photo by Asad Photo on Pexels
Read article
Discussion
Leave a comment
Thoughts, questions, corrections - all welcome.