When component tests earn their weight
A decision gate for React Testing Library component tests, which stay off by default on a Next.js SaaS and earn their place only when a named trigger fires.
A feature ships three new components, and by reflex each one gets a test.
Two months later the suite has two hundred component tests, eighty percent of them re-testing presentational primitives: that <Card> renders its children, that <Button> forwards an onClick.
The watch loop is slow enough that you tab away.
Yet the suite has caught zero production bugs; the real ones shipped through the data layer and the auth checks, where no component test was looking.
Reviewers have learned to rubber-stamp every component-test diff, because reading them never pays off.
The fix is to invert the reflex. For a Next.js web app, React Testing Library (RTL) is off by default. A component existing is not a reason to test it: you write a test only when the component crosses a specific, named threshold, and outside those thresholds the move is to delete the test, not add it.
You’ll write no RTL code in this lesson. What you’ll leave with is a five-second mental gate you run before opening a test file, one that tells you whether a component is worth its maintenance cost.
Why component tests are off by default
Section titled “Why component tests are off by default”A Next.js 16 SaaS renders most of its tree on the server, and a test is only worth writing where bugs land.
Take a Server Component that fetches a page of invoices and renders them as a list.
Where can it break? The query could be wrong, the auth check could let the wrong tenant through, the currency formatter could mangle a total.
Every one of those bugs lives in the data layer, at the seam, which your integration tests already cover.
The rendered <li> elements are the framework’s job: point RTL at that markup to assert the list has ten rows and you’re testing that Next.js can render an array, not your code.
What’s left is the Client Components, and on this stack they’re mostly thin. A form wired to a Server Action is already covered by the action’s integration test. A modal, a tooltip, a navigation menu: their behavior is either trivial or the kind you’d catch the first time you clicked it by hand. There isn’t enough bug density there, the rate at which real bugs cluster, to justify a standing test.
So the default writes itself: write no component test. The rendered surface is mostly not yours to test, the interactive surface mostly too thin to be worth it. This is the honeycomb’s “bug density follows the architecture” rule applied to UI: write the test where real bugs cluster, which on this stack is rarely the DOM.
One claim runs through the whole chapter: the thing you test with RTL is always a Client Component or a pure presentational component, never a Server Component. The framework’s support for rendering async Server Components in a test is fragile, so testing them this way fights the tool the whole way down. Server Components get their coverage at the seam, and on a money path, in the next chapter’s end-to-end tests.
The wide left band is where most of your page lives and where you don’t point RTL. The narrow accented band is the exception this lesson is about: the slice where state is the behavior, where a component test earns its keep.
The triggers that earn a test
Section titled “The triggers that earn a test”A component earns a test when its bug density justifies the maintenance cost: when the chance of a real bug, times the damage it does, outweighs the cost of keeping a test green forever. Three places tip that math reliably, plus a fourth that hides inside the other three.
The cost side is running time.
A unit test over your /lib helpers runs in about five milliseconds; an integration test with a real database and a rollback, twenty to eighty; an end-to-end test, seconds.
A component test sits in the awkward middle at a hundred to three hundred: it boots jsdom , renders the tree, and queries it.
Twenty well-chosen tests cost nothing you’ll notice; two hundred double your watch loop.
The discipline is in the count, which the triggers below govern.
Trigger 1: a shared component library
Section titled “Trigger 1: a shared component library”A <DataTable>, a <Combobox>, a <DatePicker>: a primitive consumed in thirty places across the app.
Here the cost math flips.
One bug in a shared primitive doesn’t ship one regression, it ships thirty, scattered across every screen that imports it, found one angry bug report at a time.
A single test that catches it before merge stands guard over all thirty call sites at once.
You’ve built components like this already: the form field set used on every form, the toast surface, the date-range picker leaned on by reports, invoices, and filters alike. A bug in shared infrastructure is a bad day across the whole product. Reach for RTL here.
Trigger 2: a complex stateful interactive component
Section titled “Trigger 2: a complex stateful interactive component”Some components are their state transitions: a multi-step form that reveals and hides fields based on earlier answers, like the subscribe form where choosing a plan changes which fields appear; a virtualized list with multi-select and keyboard navigation; a command palette with async search. The interesting behavior isn’t “does it render,” it’s “click here, type there, press Enter, and does the right branch fire?”
Walking the state graph by hand is unreliable: you’ll click the happy path and forget the branch where the user goes back and changes an answer. And the seam test never sees this logic: the action receives whatever the form finally submits, with no idea how many branches assembled that payload. The branch logic lives in the component or nowhere.
You need a sharper line than “complex,” because everything looks complex to the person who just wrote it. The rule of thumb: reach when the state graph has more than three distinct states, or when a single user flow spans more than two interactions. Under that, a manual click and the seam test are enough; over it, the branches multiply faster than you can hold them in your head.
Trigger 3: a critical UX path
Section titled “Trigger 3: a critical UX path”Some behaviors are too granular for an end-to-end test to bother with, yet too consequential to leave to a human remembering to check them.
The cookie consent gate exists to keep analytics from firing until the user consents. A flipped boolean or a default leaning the wrong way ships tracking without consent, direct GDPR exposure. “I clicked Accept and it stopped showing” is not a check you want riding on whoever last touched the file.
The checkout summary line is the row the user reads right before they commit money: total, tax, trial-end date. An end-to-end test walks the happy path of pick a plan, see a total, reach Stripe. What it won’t exercise is the seven content variants this one component must get right: coupon or not, trial or not, one seat or five, a tax-exclusive locale that shows tax separately. Each is a different sentence in front of the user’s wallet, and each is one RTL assertion: given these props, the user reads this total. Reach here.
The implicit fourth: an accessibility-sensitive surface
Section titled “The implicit fourth: an accessibility-sensitive surface”The fourth trigger is lighter, and it pays off for free. On a high-traffic surface, a login form or the navigation header, write a test that finds the submit button by its role and accessible name, the way a screen reader does. That test fails the instant the accessible name disappears, and that failure is the accessibility regression: a screen-reader user can no longer find the button. The bug and the test failure are the same event, so the query ladder you’ll meet later doubles as an accessibility audit. Where an accessibility regression is a genuine cost, that’s a trigger.
Now sort the components below. Does each cross a trigger, or is the experienced move to leave it alone?
Each item is a component in a typical 2026 SaaS. Decide whether it crosses one of the four triggers, or whether the experienced move is to skip the test. Drag each item into the bucket it belongs to, then press Check.
<Card> that renders whatever children you pass it, with no state<DateRangePicker> used in Reports, Invoices, and Filters<Section> wrapper that only adds vertical spacingWhen to delete a component test
Section titled “When to delete a component test”Knowing when a component test is a liability matters as much as knowing when to write one. An experienced engineer deletes more component tests than they write. Each case below is a deletion:
- A purely presentational component, such as
<Card>,<Section>, or<PageHeader>, with no state and no branching content. A glance catches any bug, so the bug density never justifies the cost. - A Server Component, async or otherwise. The framework owns the rendering, RTL’s async-component support is fragile, and the bugs live at the seam.
- Anything a Server Action’s integration test already covers. Mocking the action and asserting it had some effect re-runs the seam test from a worse vantage point.
- Anything on the end-to-end money path. If Playwright already walks it, a component test over the same flow is duplicate coverage.
- The framework-owned surface, like
<Link>,<Image>, and route-segment behavior. You don’t test Next.js; that’s the framework team’s job. - Library internals, like a rich-text editor’s commands or a virtualization library’s windowing math. The library owns those; if they break, that’s an upstream bug.
Every case shares a shape: someone else’s test or code already owns the risk. A component test there is a second lock on a door that’s already locked, and you’re the one who has to keep oiling it.
What the component test sees vs. what the seam test sees
Section titled “What the component test sees vs. what the seam test sees”The most common beginner mistake here is mocking a Server Action and asserting it wrote a row to the database. Avoiding it takes a crisp line between what a component test can see and what only the seam test can see: the two layers catch different bugs and are meant to compose.
A component test catches these; an integration test never sees them:
- A render branch that only appears because of client state
- The keyboard navigation order through a widget
- Focus returning to the trigger after a modal closes
- An error message rendered from a
useActionStatereducer - An optimistic update reconciling against the server’s real answer
- The accessible name on a dynamic button, such as
"Delete invoice INV-001", computed from props
An integration test catches these; a component test never sees them:
- Whether the Server Action actually wrote the row
- Whether the cross-tenant filter held
- Whether the audit log entry fired
- Whether the rate limiter allowed or rejected the request
The left tab is what a user could observe; the right tab is invisible in the DOM, where you could render the component a thousand times and never see whether a row was written or a tenant filter held. That’s a hard boundary about what each kind of test can physically reach, not a style preference.
So the rule that prevents the mistake: a component test that asserts on a Server Action’s database effect is mocking too deep. It has reached past its own layer into the integration test’s job, and does that job worse. The two tests compose instead. The component test trusts the action’s contract: “I called it with the right arguments and reacted correctly to its result.” The action test trusts no client: it checks that the row was really written, the tenant really scoped, the audit entry really fired. Each owns one side, and neither reaches across.
The five-second gate before you write a test
Section titled “The five-second gate before you write a test”The triggers and anti-triggers combine into one ordered checklist. Ask the cheap disqualifiers first, so one of the first four questions usually stops you before the judgment call.
- Is this a Server Component? Stop, wrong surface.
- Is this already covered by a Server Action integration test? Stop, duplicate.
- Is this a one-off, non-shared presentational component? Stop, it isn’t earning its weight.
- Is this on the money path Playwright covers? Stop, duplicate at higher cost.
- Is this a shared library, a complex state machine, a critical UX path, or an accessibility-sensitive surface? Reach.
Walk the gate below for a real candidate and watch where it lands you.
Server Components aren’t an RTL surface. Their bugs are caught at the seam, and on a money path, in the end-to-end tests.
Mocking the action and asserting its effect just re-runs the integration test from a worse vantage point.
A one-off component with no state has no bug density worth a standing test.
Playwright already walks this flow end to end. A component test over it is a second lock on a locked door.
No trigger met. Off by default means off. Let the seam test and a manual click cover it.
A trigger is met. Name which one in the PR. This is the thin slice where a component test earns its weight.
This is a mental gate, not a form: it runs in the time it takes to start typing the test file’s name.
Year one can ship zero component tests
Section titled “Year one can ship zero component tests”A small team shipping fast, on a stack where most of the UI is server-rendered and most behavior lives at the seam, is correct to write zero RTL tests in year one. That is the right call, not laziness.
The integration suite catches the seam bugs and a manual click catches the obvious ones.
The triggers you just learned are not a backlog to burn down; they tell the team when to start.
The day a <DataTable> gets its thirtieth consumer, or the checkout summary grows its seventh content variant, a trigger fires and you reach for a test.
Until then, “we don’t have component tests” is a defensible sentence, not a confession.
Five well-chosen tests beat fifty box-ticking ones.
Here is the exact test the gate exists to prevent, the kind that pads a coverage number and protects nothing:
it('renders', () => { render(<Card />);});It asserts nothing: there is no expect.
The only way it fails is if rendering <Card /> throws, making it a smoke test for a syntax error dressed up as a behavior test.
It adds a line to the coverage report and a hundred-odd milliseconds to every watch loop, all to catch a bug your type-checker already caught.
That is coverage theatre, and stopping you from writing it is the gate’s whole job.
External resources
Section titled “External resources”The thesis under this chapter is one sentence from the Testing Library docs. The other two sharpen this lesson’s load-bearing claims: write few, well-chosen tests, and keep Server Components off the RTL surface.
The more your tests resemble the way your software is used, the more confidence they give you — the principle the next lessons build on.
Kent C. Dodds' canonical case for fewer, higher-leverage tests — the testing-trophy thinking this lesson specializes to component tests.
The official docs, including the note that async Server Components aren't a unit-test surface yet — reach for end-to-end instead.