Frontend Testing Strategy at Scale: What to Test, What to Skip, and Why
A testing strategy for a large frontend team is not a pyramid with percentages. It is a set of deliberate trade-offs about where bugs are most likely to live, how expensive they are to fix in production, and how much friction each layer of testing adds for the engineers writing it.
The classic testing pyramid — many unit tests, fewer integration tests, fewest E2E — is correct as a general heuristic and wrong as a prescription. Apply it blindly to a React codebase and you end up with thousands of tests for utility functions while the critical user flows go untested because nobody wanted to set up a Playwright environment.
What follows is how we actually think about testing layers across a React codebase with 12 engineers and 400+ pages.
What Deserves Unit Tests
Unit tests are fast, cheap to write, and valuable for a specific category of code: pure logic that is input/output deterministic. The test pyramid advice to write "many unit tests" implicitly means this category. It does not mean testing React components in isolation.
Good unit test candidates:
- Data transformation functions — formatting, sorting, filtering, deriving computed values
- Validation logic — zod schemas, custom validators, sanitizers
- Business logic utilities — pricing calculations, permission checks, date arithmetic
- Custom hooks with complex state machines
Poor unit test candidates:
- Presentational components — a component that renders a button renders a button; there is nothing to assert that is not already in the code itself
- API call wrappers — you will end up mocking the thing you are testing
- Redux/Zustand selectors that are just property access
// Good unit test — pure logic, deterministic
import { describe, it, expect } from 'vitest';
import { formatPlanPrice } from '@/lib/pricing';
describe('formatPlanPrice', () => {
it('formats annual price with monthly equivalent', () => {
expect(formatPlanPrice({ annual: 120, currency: 'USD' }))
.toEqual({ display: '$120/yr', monthly: '$10/mo' });
});
it('handles zero-price plans', () => {
expect(formatPlanPrice({ annual: 0, currency: 'USD' }))
.toEqual({ display: 'Free', monthly: 'Free' });
});
});
Integration Tests: The Underinvested Layer
If you have to choose one layer to invest in, it is integration tests. They test components in real compositions, with real data shapes, and with enough context to catch the bugs that actually reach production — which are almost always interaction bugs, not logic bugs.
We use Vitest + React Testing Library. The key principle: test behaviour, not implementation:
// tests/BillingForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BillingForm } from '@/components/BillingForm';
import { server } from '@/mocks/server'; // MSW mock server
import { http, HttpResponse } from 'msw';
test('shows success state after valid submission', async () => {
const user = userEvent.setup();
render(<BillingForm tenantId="acme" />);
await user.type(screen.getByLabelText(/card number/i), '4242424242424242');
await user.type(screen.getByLabelText(/expiry/i), '12/27');
await user.type(screen.getByLabelText(/cvc/i), '123');
await user.click(screen.getByRole('button', { name: /subscribe/i }));
await waitFor(() => {
expect(screen.getByText(/subscription activated/i)).toBeInTheDocument();
});
});
test('shows field-level errors on invalid card', async () => {
server.use(
http.post('/api/billing/subscribe', () =>
HttpResponse.json({ error: 'card_declined', field: 'card_number' }, { status: 422 })
)
);
// ... exercise the form, assert error message appears
});
E2E Tests: Protect Critical Paths Only
E2E tests with Playwright are expensive: slow to run, brittle against UI changes, and complex to maintain. They earn their cost on exactly one category of test case: the flows where a failure would cost the business money directly or cause user data loss.
We protect five flows with E2E tests:
- Signup and first login
- Subscription upgrade and payment
- Publishing a live site (our core product action)
- Inviting a team member
- Password reset
Everything else is covered by integration tests. The E2E suite runs in under four minutes against a staging environment on every deploy.
// e2e/publish-site.spec.ts
import { test, expect } from '@playwright/test';
import { loginAs } from './helpers/auth';
test('user can publish a site and see live URL', async ({ page }) => {
await loginAs(page, 'editor@acme.test');
await page.goto('/dashboard/sites/demo-site');
await page.getByRole('button', { name: 'Publish' }).click();
const successBanner = page.getByRole('status');
await expect(successBanner).toContainText('Site is live');
const liveUrl = await page.getByLabel('Live URL').inputValue();
expect(liveUrl).toMatch(/^https:\/\//);
});
Testing React Server Components
RSC testing is still an evolving area. The options currently available:
- Unit test the data fetching and transformation separately — test the fetch function and the transformation logic; the RSC itself is often just composition
- Integration test via Playwright against a running Next.js dev server — tests the full server/client chain but is slower
- Use Next.js's built-in test utilities —
@next/experimental-test-utilsis available but still considered unstable
Testing AI Streaming Features
Streaming LLM responses require a different testing approach — you cannot wait for a complete response before asserting. We use MSW with a streaming handler:
// mocks/handlers/ai.ts
import { http, HttpResponse } from 'msw';
export const aiHandlers = [
http.post('/api/ai/generate', () => {
const tokens = ['Hello', ' world', '.', ' This', ' is', ' a', ' test.'];
const stream = new ReadableStream({
async start(controller) {
for (const token of tokens) {
controller.enqueue(
new TextEncoder().encode(JSON.stringify({ token }) + '\n')
);
await new Promise(r => setTimeout(r, 10));
}
controller.close();
},
});
return new HttpResponse(stream, {
headers: { 'Content-Type': 'application/json' },
});
}),
];
Getting the Team to Actually Write Tests
Technical strategy is useless without adoption. The interventions that moved the needle for us:
- PR checklist item, not a rule — "Does this change include a test for the new behaviour?" as a checkbox on the PR template. Guilt-free skipping with a comment; most engineers do not skip without a reason.
- First test pairing — for engineers new to the codebase, the first PR with non-trivial logic gets paired on the test. Takes 30 minutes, sets the pattern permanently.
- No test coverage percentage targets — coverage targets incentivise writing tests for code that is easy to test, not code that is risky. We track test count trends, not percentages.
- Fix flaky tests immediately — one flaky test in CI teaches the whole team to ignore CI. We treat a flaky test as a P1 bug.
Key Takeaways
- Unit test pure logic; do not unit test components that only render — there is nothing to assert that is not already in the JSX
- Integration tests with MSW are the highest-ROI layer — they test real component behaviour without the slowness of E2E
- E2E tests should protect five to ten critical business flows, nothing else
- For RSC, test the data layer and transformation separately; test the full server/client chain with Playwright
- Coverage targets produce the wrong tests — track trends, fix flaky tests immediately, and make tests a pairing activity for new engineers