Testing · Strategy

What to test (and what not to)

Medium ⏱ 45m #strategy

← Testing

TL;DR — Test behaviour the user can observe, through the public interface, and mock only the true system boundary. Don't test implementation details — state variable names, private methods, whether a function was called — because those tests break on refactors that changed nothing the user sees.


🧠 Mental model

Every test sits somewhere on a spectrum between two failure modes:

  tests coupled to CODE  ←─────────────────────→  tests coupled to BEHAVIOUR
  (implementation details)                        (user-observable contract)

  ❌ false failures on refactor          ✅ survive refactors
  ❌ green while feature is broken        ✅ fail when the feature breaks

A false negative is a test that goes red when nothing actually broke (you renamed a hook, reshaped internal state). A false positive is a test that stays green while the feature is broken (you asserted a mock was called, but the mock's real counterpart is wired up wrong). Implementation-detail tests give you both. The fix is the same guiding principle every time: the more your tests resemble the way the software is used, the more confidence they give you.

⚙️ How it actually works

The practical test is: would a user or a caller of this module notice if this changed? If yes, it's behaviour — test it. If no, it's an implementation detail — leave it alone.

Test this (observable contract) Don't test this (implementation detail)
What renders for a given prop/state The name of a useState variable
What happens when the user clicks/types Whether an internal helper was called
The returned value / thrown error of a fn Private methods, module-internal state
Accessible output (roles, labels, text) Exact DOM structure / class names
Error and empty and loading states The framework's own behaviour

What to mock is the other half of the question. Mock at the architectural boundary — the network, the clock, crypto.randomUUID, localStorage — the things that are slow, non-deterministic, or have side effects you can't run in CI. Do not mock your own modules to make a component testable; that's a smell that you're testing the seam instead of the behaviour. Mock the network with MSW so the component still exercises its real fetch-parse-render path; only the wire is faked.

Two more heuristics that separate senior from junior test suites:

  • Test the risky and the reused, skip the trivial. A currency formatter used in 40 places and a checkout reducer earn tests. A one-line prop pass-through does not. Every test is code you maintain forever — spend the budget where a bug would actually hurt.
  • Cover the states, not the lines. Loading, empty, error, success, and boundary inputs (0, '', null, huge lists) are where real bugs hide — not the happy path you already eyeballed.

💻 Code

// ❌ Testing implementation details — brittle and low-confidence
test('sets loading state', () => {
  const { result } = renderHook(() => useSearch());
  act(() => result.current.search('x'));
  expect(result.current.isLoading).toBe(true); // internal flag, not user-facing
});
// This goes red the day you rename `isLoading` → `pending`, even though
// the UI is identical. And it never proves a spinner ever appears.

// ✅ Testing behaviour — resilient and meaningful
test('shows a spinner while searching, then results', async () => {
  render(<Search />);
  await userEvent.type(screen.getByRole('searchbox'), 'shoes');
  expect(screen.getByRole('status')).toBeInTheDocument();          // user sees loading
  expect(await screen.findByRole('list')).toHaveTextContent(/shoe/i); // then results
});
// ❌ Asserting a mock was called proves nothing about the outcome
expect(trackEvent).toHaveBeenCalledWith('add_to_cart'); // it fired… and then?

// ✅ Assert the observable effect instead
expect(screen.getByRole('status')).toHaveTextContent('Added to cart');

toHaveBeenCalled has one legitimate home: verifying a side-effecting boundary you deliberately mocked (an analytics beacon, an API POST) whose effect is genuinely invisible in the UI. Everywhere else, assert the result.

⚖️ Trade-offs

  • "Behaviour only" has a cost: behavioural tests are slower to write and slower to run than a unit test of a pure function, and when they fail the failure can be less pin-pointed. Worth it — a failing behavioural test means something real broke — but don't pretend it's free.
  • When implementation tests are legitimate: a performance-critical internal (memoisation actually memoises, a cache evicts correctly) is a case where the "implementation" is the contract. Test it, but name it honestly.
  • Don't test the framework. React re-renders correctly; useState works. Testing that you can call setState is testing React's maintainers' job, not yours.

💣 Gotchas interviewers probe

  • "Assert the mock was called" is the classic anti-pattern. It's a false-positive factory: the mock is called, the test is green, and the real integration is broken. Assert the effect, not the call.
  • Snapshotting a whole component tree is testing implementation detail in disguise. It fails on every innocuous markup change and asserts nothing about correctness.
  • Chasing coverage forces bad tests. To hit an untested branch you'll write a test that pokes internals. Coverage should be a byproduct of testing behaviour, never the target.
  • Testing the happy path only. The interviewer wants to hear "error, empty, loading, boundary." That's where production breaks.
  • Over-mocking. If half your test is jest.mock calls, you've replaced the thing you meant to test with a fiction, and green means nothing.

🎯 Say this in the interview

"My rule is: test behaviour the user or caller can observe, and mock only the real system boundary. Concretely, I ask 'would someone notice if this changed?' — if yes it's behaviour worth a test, if no it's an implementation detail like a state variable name, and testing it just creates false failures on refactor. The anti-pattern I actively avoid is asserting a mock was called, because that's green even when the real integration is broken; I assert the visible effect instead. I mock at the architecture boundary — network via MSW, the clock, randomness — never my own modules. And I prioritise by risk: the reused formatter and the checkout reducer get tests, the trivial pass-through doesn't, and I always cover the loading, empty, and error states because that's where real bugs live, not the happy path I already looked at."

🔗 Go deeper

Primary source — the single best place to go deeper Kent C. Dodds: write tests
ESC

Type to search across every question bank.

navigate open 3,610 questions indexed