Test behaviour, not implementation, or your tests become the bug
There are two ways to test the same component, and they age in opposite directions. A test that asserts on implementation — internal state, private methods, which hooks fired — breaks every time you refactor, even when the user-facing behaviour is identical. A test that asserts on behaviour — what the user sees and can do — survives refactors and fails only when something actually breaks. The distinction decides whether your test suite is an asset that lets you refactor fearlessly or a liability that punishes every internal change. A brittle suite that cries wolf on every rename does not protect you; it trains you to ignore it, which is worse than no suite at all.
The brittle version tests the wrong thing
An implementation test reaches into the component’s guts — its state variable names,
its internal methods. Rename the state, split the component, or swap useState for
useReducer, and it fails though the user sees no difference:
// BRITTLE: asserts on internal state — a refactor renames `count` and this breaks
const { result } = renderHook(() => useCounter());
expect(result.current.internalCount).toBe(0); // couples the test to the implementation
Every false red erodes trust, and a suite you do not trust is a suite you route around.
The durable version tests what the user does
A behaviour test drives the component the way a user would — find the element by its visible role or text, interact, assert on what shows — and never mentions an internal:
// DURABLE: interacts like a user; survives any refactor that keeps behaviour intact
render(<Counter />);
await userEvent.click(screen.getByRole("button", { name: /increment/i }));
expect(screen.getByText("1")).toBeInTheDocument(); // asserts what the user sees
Rewrite Counter however you like — hooks, class, reducer — and this test stays green
as long as clicking increment still shows 1. It goes red exactly when the behaviour
regresses, which is the only time you want to be interrupted.
Query the way a user perceives the app
The practical rule that keeps you honest: query by what a user perceives — role,
label, visible text — not by test ids, class names, or component internals. That is why
testing-library’s getByRole/getByText are designed the way they are: they nudge you
toward behaviour and away from implementation, and as a bonus, getByRole fails if the
element is not accessible, so a behaviour test quietly checks accessibility too. There
is one honest exception — a pure function has no “implementation vs behaviour”
distinction, so testing a reducer’s return value directly is behaviour testing. But for
components, assert on the rendered result and the user interaction, never the internal
state, and your suite becomes the thing that enables refactoring rather than
resisting it. The counter-reducer exercise is the pure-function case, and
accessible-combobox is where behaviour-first querying and accessibility checking turn
out to be the same act.