Free lesson · React Deep Dive
Testing Philosophy: What to Test, What Not To
Lesson 23.1 - Testing Philosophy: What to Test, What Not To
The foundational lesson.
Before tools, before patterns - the philosophy. What is testing for? What kinds of tests are worth writing? What kinds are net-negative? The technical lessons that follow only make sense once these questions have defensible answers. Engineers who skip this lesson write technically correct tests that solve the wrong problem.
This lesson covers the philosophy I'd recommend internalizing: tests should give confidence, not coverage. Tests are tools for catching real bugs and supporting refactors. Tests that fail this purpose - that catch hypothetical bugs, that break on benign refactors, that increase fear of changing code - are net-negative even when they technically pass.
By the end, you'll have a framework for deciding what to test, what to skip, and why your existing testing instincts might be wrong.
What Tests Are For
A specific starting frame. Tests serve two specific purposes:
-
Catching real bugs. A test fails; you find a bug before users do. The test paid for itself.
-
Enabling refactors with confidence. You change implementation; tests pass; you know you didn't break user-visible behavior. The test paid for itself again.
Tests that don't serve these purposes are overhead. Tests that block these purposes (by breaking on refactors that preserve behavior, by passing when bugs exist) are actively harmful.
The mistake most engineers make: treating tests as a goal in themselves. "We need 80% coverage." "Every component needs a test." These goals produce a lot of tests; they don't produce confidence.
A better frame: tests are investments. They cost to write, cost to maintain, cost when they break. The return is confidence. Like any investment, the right question is "will this return more than it costs?" Some tests have great ROI; others lose money. Knowing which is the discipline.
The Testing Trophy
The conceptual model. Kent C. Dodds (the React Testing Library maintainer) framed this as the Testing Trophy - an inversion of the older Testing Pyramid:
flowchart TB
Static["Static Analysis<br/>(TypeScript, ESLint)<br/>cheapest, prevents whole bug categories"]
Unit["Unit tests<br/>(pure functions, isolated logic)<br/>fast, narrow"]
Integration["Integration tests<br/>(components in context)<br/>most value per test"]
E2E["End-to-end tests<br/>(full app, real browser)<br/>expensive, broadest"]
Static --> Unit
Unit --> Integration
Integration --> E2E
style Integration fill:#a78bfa,stroke:#8b5cf6,color:#000
style Static fill:#34d399,stroke:#10b981,color:#000
A diagram of the Testing Trophy - the modern model for how to think about test types. The base layer is static analysis: TypeScript, ESLint, type checking. This is the cheapest layer; you write the types once and they prevent entire categories of bugs forever. Up from there is unit tests - for pure functions, utility code, isolated logic. Fast to write and run, but narrow in what they verify. The middle layer, and the largest by intentional emphasis, is integration tests. These render components with their realistic context - providers, child components, real interactions. They give the most value per test because they verify how pieces work together, which is where most user-facing bugs actually live. At the top is end-to-end tests: the full app running in a real browser, exercised like a user would. These are the most expensive in time and maintenance but verify the actual user experience. The trophy shape - wider in the middle - reflects where most testing effort should go. Most apps over-invest in unit tests and under-invest in integration tests; the trophy is a correction.
The shape matters. The middle is widest because integration tests catch the most user-visible bugs per test written. They verify components in their realistic context - providers, child components, real interactions. Most bugs live at integration points; that's where to test.
The pyramid (older model) had unit tests as the widest base. The reasoning was "unit tests are fast; write lots of them." This produced apps with thousands of unit tests that didn't catch real bugs because real bugs happen at integration boundaries, not inside isolated units.
The trophy correction: don't optimize for test count; optimize for value per test. Integration tests win on this metric for React apps.
The Most-Repeated Quote in This Space
Kent C. Dodds: "Write tests. Not too many. Mostly integration."
Unpack the three parts.
"Write tests." Don't skip testing entirely. Codebases without tests degrade faster than codebases with good tests; refactoring becomes scary; regressions accumulate.
"Not too many." Tests have cost. Every test is code to write, run, maintain. Too many tests is a problem - refactors take ages because every implementation change breaks dozens of tests. Aim for fewer, better tests.
"Mostly integration." Skew toward integration tests. Unit tests for pure logic where they're cheap and high-value. E2E tests sparingly for the most important user flows. The middle (integration) is where most tests should live.
This single sentence captures most of testing wisdom for React apps. The lessons that follow are the operationalization.
What Real Bugs Look Like
A specific exercise. Think about the last 10 bugs you fixed in a React app. Where did they live?
In my experience, the breakdown is roughly:
- ~40% - Integration issues. Component A passes wrong data to component B. State updates don't propagate. Effects run in the wrong order.
- ~20% - User flow issues. "When I click X then Y then Z, the wrong thing happens." Multi-step interactions.
- ~15% - Async issues. Race conditions. Stale data. Cancellation issues.
- ~10% - Edge cases in data. Empty arrays. Null values. Unexpected formats.
- ~10% - Browser/environment issues. Specific browser quirks. Mobile-only bugs.
- ~5% - Pure logic bugs in isolated functions.
Notice where the bugs cluster: integration, user flows, and async. Unit tests of pure functions catch ~5% of real bugs. Integration tests catch the bulk.
This is why the trophy emphasizes integration. You write tests to catch the bugs that actually happen, not the bugs that hypothetically could happen.
What to Test
A specific list of what's worth testing:
User-visible behavior. "When the user clicks Submit, the form data is sent to the API." "When the user types in the search box, the list filters." These are tests that verify the app does what users expect.
Critical paths. Authentication. Payment flows. Anything where a regression would be a serious incident. These deserve more thorough testing.
Edge cases that have caused bugs before. Once a bug has happened, a test that catches it next time has high value. The bug proved the test was needed.
Pure logic with complex rules. Tax calculations. Date arithmetic. Validation logic. If it's logic that's easy to get wrong, a test catches mistakes early.
Documentation by example. Sometimes a test is the clearest specification of what a component does. The test serves dual purpose - verification and documentation.
What NOT to Test
The complementary list. Some of these are controversial; I'd argue they shouldn't be.
Implementation details. "Component re-renders when props change." "useState is called with the right initial value." These tests fail on refactors that don't change behavior. They are net-negative. Delete them.
Simple display components. A <UserAvatar src={...} /> that renders an <img> doesn't need a test. The complexity is zero; the test verifies that <img> works (which it does, because React works).
Third-party libraries. Don't test that React Hook Form validates correctly - that's RHF's job to test. Don't test that React Router navigates - that's React Router's job. Test your code, not your dependencies.
Snapshot tests of components. A snapshot test takes the rendered output of a component and compares to a stored snapshot. Updates require a "is this the same?" judgment. Most teams update without reading; the test becomes meaningless. (Snapshots are useful for very specific cases - config-like data, contract testing - not for component output.)
Internal component state. Resist the urge to test that "the open state is true" or "the loading state changed." Test the user-visible effect of those states - "the modal is visible" or "the loading spinner shows" - not the state itself.
Every prop combination. A component with 5 boolean props has 32 combinations. Don't test all 32. Test the meaningful behavior; trust composition for the rest.
Code that hasn't shipped yet. Speculative tests for code that might exist someday is waste. Write tests as the code reaches stability.
The Test Cost-Benefit Equation
A specific framing. For each potential test, ask:
-
What's the cost? Time to write. Time to maintain. Time when it breaks. Cognitive load on developers reading it.
-
What's the benefit? Bugs it catches. Confidence it provides. Documentation value.
-
Is benefit > cost?
For most "should I write this test?" decisions, this framing produces the right answer. Tests for critical user flows: benefit > cost; write them. Tests for trivial display logic: cost > benefit; skip them.
A specific gotcha: costs include future costs. A test that's easy to write now but breaks on every refactor for the next two years has high long-term cost. The bias toward "more tests" usually undervalues this.
The discipline: skip the tests where benefit is dubious. The codebase is better for not having them.
The "Delete the Test" Principle
A specific norm worth adopting. When a test breaks during a refactor that doesn't change user-visible behavior - consider deleting the test.
The reasoning: the test was testing implementation, not behavior. If the user-visible behavior is unchanged but the test broke, the test was coupled to the old implementation. That coupling is what's wrong. Deleting the test removes the coupling.
Common pushback: "But if we delete the test, we lose coverage."
The response: what coverage? If the test verified implementation details rather than behavior, it wasn't catching real bugs. Deleting it doesn't reduce confidence; it reduces the false sense of confidence the test provided.
The norm is countercultural in some organizations. "Don't delete tests" is a common (bad) rule. Bad tests are worse than no tests. They take time to maintain; they create fear of refactoring; they pass when bugs exist; they obscure what's actually being verified. Deleting them is positive value.
A more measured version: rewrite, then delete. When you find a test testing implementation, rewrite it to test behavior. If you can't (because the behavior is already covered by another test, or because the behavior is too trivial to test), delete the original.
A Specific Anti-Pattern: Coverage as Goal
A specific norm to push back on. "We need X% code coverage."
Code coverage measures what percentage of lines are executed by tests. It doesn't measure whether tests catch bugs. A codebase can have 100% coverage with garbage tests - tests that exercise every line without verifying anything meaningful.
Coverage as a metric is correlated with test quantity, not test quality. Optimizing for coverage produces lots of tests. Many of them are bad tests (because they're written to hit coverage, not to catch bugs).
The healthier metric: does this codebase catch bugs before users do? This is harder to measure directly but more honest. Approximations: number of regressions per month, mean time to detect, developer confidence in refactoring.
For most projects, a healthy testing practice produces 60-80% coverage organically. If you're at 30%, you probably have too few tests. If you're at 95%, you probably have many bad tests. Coverage is a sanity check, not a target.
A Worked Example: Two Versions of the Same Test
To make the philosophy concrete. A login form. Two versions of testing it.
Bad Test (Implementation Details)
it('renders email and password inputs and calls login on submit', () => {
const wrapper = shallow(<LoginForm />)
expect(wrapper.find('input[type="email"]')).toHaveLength(1)
expect(wrapper.find('input[type="password"]')).toHaveLength(1)
expect(wrapper.state('email')).toBe('') // testing internal state
wrapper.setState({ email: 'olena@example.com', password: 'pass' }) // setting internal state
wrapper.find('form').simulate('submit')
// ...
})
Problems:
- Uses Enzyme's
shallowandfind- testing rendered tree structure. - Tests internal state (
wrapper.state('email')). - Sets state directly (not how users interact).
- Would break if you refactored to use React Hook Form (different internal structure).
Good Test (User Behavior)
it('logs in when email and password are submitted', async () => {
const user = userEvent.setup()
const mockLogin = vi.fn()
render(<LoginForm onLogin={mockLogin} />)
await user.type(screen.getByLabelText(/email/i), 'olena@example.com')
await user.type(screen.getByLabelText(/password/i), 'mypassword')
await user.click(screen.getByRole('button', { name: /log in/i }))
expect(mockLogin).toHaveBeenCalledWith({
email: 'olena@example.com',
password: 'mypassword',
})
})
What's different:
- Uses
userEventto actually type/click like a user. - Queries by accessible labels (
getByLabelText) and roles (getByRole) - what users see. - Doesn't touch internal state.
- Works regardless of how the form is implemented internally.
The good test would survive a refactor from useState to React Hook Form. From a controlled to uncontrolled implementation. From a class component to functional. As long as users still see email and password inputs and a login button, the test passes.
This is the philosophy in concrete form. Test what users see and do. Don't test how the implementation gets them there. The implementation is allowed to change; the user-visible behavior is what your tests verify.
A Note on Coverage of Refactors
A subtle point. Sometimes the right behavior changes too - you refactor a component AND change what it does. In that case, tests SHOULD break; you need to update them.
The discipline is: distinguish the two cases.
- Refactor without behavior change → tests should pass. If they break, they were testing implementation.
- Refactor with behavior change → tests SHOULD break to catch any unintended changes. Then you update them to reflect the new intended behavior.
The first case is the one to be vigilant about. The second is normal test maintenance.
What I Want You to Take From This Lesson
Six things.
One: Tests are investments. Cost to write, cost to maintain, return in confidence. Some have great ROI; others lose money.
Two: The Testing Trophy puts integration in the middle. Most React bugs are at integration points; that's where most tests should live.
Three: "Write tests. Not too many. Mostly integration." The single best summary of testing wisdom.
Four: Test user-visible behavior, not implementation details. Tests that break on benign refactors are testing the wrong thing.
Five: Coverage isn't the goal. Confidence is. They're correlated but not the same.
Six: Delete tests that break on behavior-preserving refactors. Bad tests are worse than no tests.
Try It
Exercise: Warmup (20 minutes). Audit a project's tests. Categorize them: tests user-visible behavior vs implementation. Note the ratio.
Exercise: Core (30 minutes). Pick three tests that test implementation. Rewrite them to test behavior. Note what was being verified that didn't need to be.
Exercise: Core (30 minutes). For the last 10 bugs in your project, categorize them: integration, user flow, async, edge case, environment, pure logic. Note where the bugs cluster.
Exercise: Stretch (45 minutes). Write a test for the most important user flow in your app - login, checkout, primary feature use. Verify only the user-visible behavior.
What's Next
Lesson 23.2 covers React Testing Library - the library and the API that makes user-behavior testing practical.