Why I Don't Test Custom Hooks (Most of Them)
A UI testing strategy that survives refactors: test from the user's side, treat presentation hooks as implementation detail, and only test hooks in isolation when they're genuinely reusable utilities. Less fragile tests, fewer rewrites, more confidence.
I’ve watched the same painful PR show up at every team I’ve joined.
A junior engineer is asked to refactor a component. They split a custom hook in two, rename a piece of state, change which value is useMemo’d versus inlined. The component still works. The screen still looks identical. The user-visible behavior is unchanged.
And eighty test files break.
Why? Because the tests were asserting on the hook’s return values, on render counts, on internal state shape. The contract being tested wasn’t “this UI does what the user expects”. The contract was “this hook returns these specific values in this specific order” — a contract no real user has ever signed.
This article is the long version of the testing rule I defend in every code review I run: test the UI from the user’s side. Don’t test the hook that makes it work. Save hook tests for genuinely reusable utilities.
It’s an opinionated take. It also happens to be the one strategy I’ve seen survive a multi-year refactor without rewriting half the test suite along the way.
The principle in one line
A test should describe a contract a real user has with the UI. Anything below that line — internal state, hook return values, custom-hook side effects — is implementation detail, and tests on implementation detail die in the next refactor.
That’s it. The whole strategy is downstream of that one sentence.
If a test breaks when you change how the component works, but nothing changed in what the user sees and does, the test was wrong. Not the refactor.
Why testing presentation hooks is fragile
This codebase, like most modern React codebases, splits view from logic at the component level: every component has a sibling useXxx.ts hook that owns state, derivation, and effects. The pattern is great for readability — and it’s a trap if you start testing those hooks in isolation.
export function ProjectsGrid({ projects }: ProjectsGridProps) { const { selected, openProject, closeDialog, isDialogOpen } = useProjectsGrid(projects);
return ( <> <ul className="projects-grid"> {projects.map((project) => ( <ProjectCard key={project.id} project={project} onClick={() => openProject(project)} /> ))} </ul> <ProjectDialog project={selected} open={isDialogOpen} onClose={closeDialog} /> </> );}Now imagine two test strategies for this component.
The fragile strategy — test the hook directly:
// Don't do thisimport { renderHook, act } from '@testing-library/react';
it('opens the dialog when openProject is called', () => { const { result } = renderHook(() => useProjectsGrid([projectA, projectB])); act(() => result.current.openProject(projectA)); expect(result.current.isDialogOpen).toBe(true); expect(result.current.selected).toBe(projectA);});This test is asserting on three things that are not part of any user-facing contract: the existence of isDialogOpen, the name selected, and the shape of openProject’s argument. None of these are visible to the user. All three are at the mercy of the next refactor.
What happens when I rename selected to activeProject? Test breaks. When I move dialog state into a useReducer? Test breaks. When I split this hook into useDialogState + useProjectSelection? Test breaks. The component still works. The user sees no difference. And I have to update test files anyway, because I made the test depend on the wrong contract.
The durable strategy — test the rendered UI:
import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';
it('opens the project dialog when a card is clicked', async () => { render(<ProjectsGrid projects={[projectA, projectB]} />);
await userEvent.click(screen.getByRole('button', { name: projectA.title }));
const dialog = await screen.findByRole('dialog'); expect(dialog).toHaveTextContent(projectA.title); expect(dialog).toHaveTextContent(projectA.description);});Read both side by side. The second test asserts on what the user sees and does: clicking a card surfaces the dialog with the right content. It says nothing about hooks, state names, or rendering strategy.
I can rewrite the entire internal hook tomorrow. I can replace the dialog with a side panel. I can swap useState for useReducer for useSyncExternalStore. As long as clicking a project still opens the right detail view, this test passes. That’s the contract that matters. That’s the contract a user actually has.
Tests are a contract — make sure you’re signing the right one
When I’m reviewing tests, the first question I ask is: whose contract is this enforcing?
- “When the user clicks the card, the dialog opens with the project name.” → user contract. Worth a test.
- “When
openProjectis called with a project,selectedbecomes that project.” → developer contract. Not worth a test, because no user ever depends onselected. - “When the form is submitted with valid data, a confirmation message appears.” → user contract. Worth a test.
- “The
useFormhook returns{ values, errors, handleSubmit }.” → developer contract. The structure is for me, not for users. Don’t test it; if I refactor it, the test should not be in the way.
Every line of test code costs you something — to write, to maintain, to read, to update through refactors. Spend that budget on the contracts the business cares about, not the contracts your editor cares about.
When testing a hook directly is right
I’m not anti-hook-testing. I’m anti-testing-hooks-that-aren’t-units.
There’s a clear category where renderHook is exactly the right tool: reusable utility hooks. These are hooks that:
- Live independently of any specific component.
- Will be imported by multiple consumers.
- Have a well-defined input/output contract that you genuinely intend to keep stable.
- Can be reasoned about without rendering a component tree.
The classic examples — useDebounce, useThrottle, useLocalStorage, useMediaQuery, usePrevious, useInterval. These are libraries, in spirit. They are units. Their consumers depend on their behavior the way you depend on lodash.debounce. You absolutely should test them directly:
import { renderHook, act } from '@testing-library/react';import { vi } from 'vitest';
it('only emits the latest value after the wait window', () => { vi.useFakeTimers(); const { result, rerender } = renderHook( ({ value }) => useDebounce(value, 200), { initialProps: { value: 'a' } }, );
rerender({ value: 'b' }); rerender({ value: 'c' }); act(() => { vi.advanceTimersByTime(200); });
expect(result.current).toBe('c');});This test is right because the contract being asserted is the contract the hook publishes to its callers. Anyone who imports useDebounce is going to depend on exactly this behavior. The contract is real, stable, and worth pinning down.
The decision rule is short:
A hook glued to one component is implementation detail. Test the component. A hook used by multiple components is a unit. Test the hook.
Where to draw the line — a concrete checklist
When a teammate asks me whether to test a hook in isolation or via the UI, I walk them through these questions in order:
-
Is this hook imported by more than one consumer today? No → presentation detail, test the UI. Yes → keep going.
-
Will this hook be imported by more than one consumer in the foreseeable future? No → presentation detail, test the UI. Yes → keep going.
-
Does the hook’s API have a name and a shape you’d defend in a code review? (i.e., is it stable enough that you’d hesitate to rename it) No → presentation detail, test the UI. Yes → it’s a unit. Test the hook.
-
Could a user notice if this hook misbehaved, even indirectly? Yes → also worth a UI test that exercises the consumer. No → unit test alone is enough.
The bias is intentional. The default is “test the UI”. You only escape that default when you have a real reason to.
What “test the UI” actually looks like
I keep this guidance handy when reviewing component tests. It’s the difference between a test that survives a refactor and one that doesn’t.
Use accessible queries. getByRole, getByLabelText, findByText. They mirror how a user (or assistive technology) finds elements. They’re also stable across DOM-structure refactors:
// good — survives className changes, structural refactorsscreen.getByRole('button', { name: 'Open project' });
// bad — first to die when someone renames a classcontainer.querySelector('.project-card__cta');Use userEvent, not fireEvent. userEvent simulates real user interactions (focus, keyboard, pointer events in the right order). fireEvent synthesizes one event in isolation, which can pass a test that a real user couldn’t actually trigger.
Assert on what’s rendered, not on props. The user can’t see props. They see text, ARIA labels, visible state. Assert on those.
Use MSW for the network layer. When tests need data, mock at the HTTP boundary. The repository, the use case, the component — none of them need to know they’re being tested. They make the same calls they’d make in production. (This codebase’s repositories are in-memory today, so MSW comes in once we have HTTP repositories. Same principle.)
Don’t mock components. A test that mocks the <Dialog> component to check that it was called is a developer-contract test. A test that finds role="dialog" in the DOM is a user-contract test. Mocking child components is almost always a sign you should be writing the test at a different level.
What you give up
I don’t want to oversell this. Testing from the UI has real trade-offs, and pretending otherwise is the kind of dishonest article I try not to write:
- Tests run slower. Mounting a component tree is more expensive than calling a function. On a small codebase you won’t feel it. On a thousand-test suite you will.
- Failures point further from the cause. A red test tells you “clicking the card doesn’t open the dialog”. The actual bug might be in a hook, a reducer, a context provider, a CSS rule that hides the dialog. You spend more debug time than you would with a tightly scoped unit test.
- Setup is sometimes verbose. Some components need a router, a query client, a theme provider. You’ll build a
renderWithProvidershelper, and you’ll use it everywhere. - You write fewer tests. A UI test usually covers what 4–5 hook tests would have covered. That’s a feature, not a bug — but it does mean your “test count” goes down. If your team measures testing by quantity, brace yourself for the conversation.
These are real costs. The reason I pay them anyway is that the alternative is paying the same cost on every refactor, forever. Slower tests are a one-time tax. Tests coupled to implementation are a recurring tax that scales with team size and codebase age.
The smells I look for in code review
A short field guide:
renderHookfor a hook used in exactly one component. Move the test up to the component.- Assertions on
result.current.someInternalField. That field is implementation detail. The user doesn’t see it. - Tests that break when you rename internal variables. The contract is wrong. Rewrite the test against the rendered UI.
jest.mock('./useThing')orvi.mock('./useThing')to isolate a component. The test you’re writing isn’t a component test, it’s a hook contract test. Reconsider whether you want either.- More test files than component files. Often a sign that hooks are being tested in addition to their consumers, doubling the coverage and the maintenance.
- Snapshot tests for component output. Snapshots assert on the entire tree, including every implementation detail in it. They break on every refactor. Replace with targeted assertions on user-visible content.
The honest summary
The goal of testing isn’t coverage. It isn’t a green bar. It isn’t a quantity number on a dashboard. The goal of testing is confidence — the confidence to refactor a component, change a hook, swap a state library, or onboard a new engineer, without breaking what the user sees.
A test suite that’s pinned to internal hook shapes gives you the opposite of confidence. Every refactor becomes a negotiation with the test suite. The tests stop describing the product and start describing whichever implementation existed the day they were written. Eventually nobody trusts them, because everybody has spent an afternoon “fixing” tests that weren’t actually broken.
The fix is small but disciplined: test the UI, not the hook. Reserve hook tests for utilities that are genuinely shared. Once you internalize that, your test suite stops being a tax on changes and starts being what it was supposed to be — a safety net under the work.
Tests should describe contracts that survive refactors. The user has a contract with the UI. The UI has a contract with the developer. Tests written against the first one are durable. Tests written against the second one are scaffolding you’ll have to take down every time you change the building.