Skip to content
All articles
JestReact Testing Library

Writing Tests That Matter: My Jest + RTL Approach

A testing philosophy that balances coverage with velocity — no 100% coverage dogma here.

20 April 20268 min read

The Testing Pyramid Is Wrong for Frontend

The traditional testing pyramid says you should have lots of unit tests, some integration tests, and a few end-to-end tests. For backend services, this makes sense. For frontend applications, I've found the inverse is more useful: a few unit tests for complex logic, lots of integration tests for user flows, and targeted E2E tests for critical paths.

The reason is that most frontend bugs aren't logic errors — they're integration errors. A component works perfectly in isolation but breaks when composed with other components, or when the API returns slightly different data than expected. Unit tests for individual components don't catch these issues. Tests that render multiple components together and simulate user interactions do.

Test User Behavior, Not Implementation

React Testing Library enforces this by design, and I love it for that. When I write a test, I write it from the user's perspective. I don't test that a state variable changed — I test that a button appears or disappears. I don't test that a function was called — I test that the UI reflects the result of that function call.

This approach makes tests resilient to refactoring. I've rewritten component internals from class components to hooks to server components, and the tests passed without modification because they tested what the user sees, not how the component works internally.

The one exception: custom hooks that contain complex business logic. For these, I use renderHook from RTL to test the hook in isolation. But even then, I test inputs and outputs, not intermediate state.

My Testing Strategy by Component Type

Pure presentational components get snapshot tests and visual regression tests. They have no logic to test — I just need to know if they look wrong.

Interactive components get integration tests that simulate user interactions: clicks, typing, form submissions. I test the happy path, one error path, and any edge case that's bitten me before. I don't test every possible error — that's diminishing returns.

Data-fetching components get tests with mocked API responses using MSW (Mock Service Worker). I test loading states, success states, error states, and empty states. MSW intercepts at the network level, so the component code doesn't know it's being tested.

Complex business logic gets unit tests with Jest. Price calculations, date formatting, validation rules — these are pure functions that deserve thorough test coverage because they're easy to test and expensive to get wrong.

The 80% Coverage Sweet Spot

I've worked in codebases with 95% test coverage and codebases with 40% coverage. The 95% codebase wasn't twice as reliable — it was just harder to modify because every change required updating a dozen tests.

I aim for 80% line coverage with a focus on covering critical paths. The last 20% of coverage is usually error boundaries, edge cases in UI states, and platform-specific branches that are expensive to test and unlikely to break.

The metric I actually care about is 'did the tests catch a real bug this month.' If the answer is consistently no, either your tests are testing the wrong things or your code quality is already very high. In my experience, it's usually the former.

Making Tests Fast

Slow tests don't get run. At CARS24, our test suite took 8 minutes, and engineers started skipping it locally and relying on CI. We got it down to 90 seconds by switching from jsdom to @testing-library/react's built-in rendering, parallelizing test files with Jest workers, and replacing heavyweight mocks with lightweight stubs.

The biggest speed win was eliminating unnecessary rerenders in tests. Many tests triggered component re-renders by updating state unnecessarily. Wrapping state updates in act() and batching related operations cut our test execution time by 40%.

Fast tests create a virtuous cycle: engineers run them more often, catch bugs earlier, and trust the test suite. Slow tests create a vicious cycle: engineers skip them, bugs reach production, and nobody trusts the tests.

Found this useful? I write about engineering, performance, and career growth.