Skip to content
Chapter 89Lesson 4

Five components that earn a test

A worked catalog of five real components, deciding for each which React Testing Library test earns its place and where another layer takes over.

You have three pieces in place: the triggers that tell you when a component earns a test, off by default; a render helper that mounts a component and hands you a simulated user; and the query ladder, which finds elements the way a real user reaches them, role first.

What you don’t yet have is a sense of where a component test stops. The triggers say whether to test and the ladder says how, but neither says which behaviors to hand off to the seam test or to end-to-end. Drawing that line is this lesson’s skill, built through five real components from the app you’ve been building, each decided with the same three questions: which trigger it meets, what behaviors to assert, what to leave to another layer. The one new pattern along the way is mocking a Server Action at its import.

There are five entries because five distinct shapes of decision are worth seeing, not because a first-year codebase needs five tests. It might have one or two, and “zero, so far” is a sound answer.

Component
Trigger met
Left to someone else
Cookie consent gate
Critical UX
PostHog gate fires → E2E
Subscribe form
Complex state
DB write + Stripe → seam
Date-range picker
Shared library
library internals
Data table
Shared library
pagination → seam
Checkout summary line
Critical UX
Stripe redirect → E2E
Five components that earn a test, each with exactly one trigger and a clear line on what someone else covers.

Notice the third column: every component here has part of its story told by another layer. A component test that doesn’t know where it stops is the most common way these tests go wrong, so we settle that line first.

Mock the action at its import, not the database

Section titled “Mock the action at its import, not the database”

Every form-shaped entry in the catalog hits the same wall. A client component imports a Server Action and calls it, through useActionState or directly. Your test mounts it in a fake browser, where there is no server and no database. So what runs when the form submits?

Not the real action. Running it and checking the database afterward is the over-reach the first lesson named “mocking too deep” : the test re-tests work the seam test already owns, costs more, and welds two layers together so a change in either breaks both. A component test should never know whether a row got written.

Replace the action at the import instead. Vitest hands a fake module to the import; you decide per test what that fake returns.

SubscribeForm.test.tsx
import { vi } from 'vitest';
import { createSubscription } from '@/server/actions/createSubscription';
import { Result } from '@/lib/result';
// Hoisted: replaces the whole module before any test runs.
vi.mock('@/server/actions/createSubscription', () => ({
createSubscription: vi.fn(),
}));
// Per test, pick the branch — approved | declined:
vi.mocked(createSubscription).mockResolvedValue(Result.ok({ subscriptionId: 'sub_123' }));
vi.mocked(createSubscription).mockResolvedValue(Result.err({ code: 'CARD_DECLINED' }));

Result is the contract the form and the action agree on. vi.mock replaces the whole module before any test runs, and vi.mocked(...).mockResolvedValue(...) picks which branch a test exercises. The mocked action exists only to drive a branch, making the form behave as if the card was approved or declined. It is not the thing under test.

That is the whole discipline. The form test owns the form’s contract with the action: that valid data calls the action, that an ok result shows the success state, that an err result shows the right error. The action test owns the action’s body: that it writes the row, logs the audit, calls Stripe, and holds the tenant boundary — the seam tests from last chapter. The two compose instead of overlapping: the form test trusts the Result contract and never looks past it; the action test verifies its own effects. Test each thing once, at the layer that owns it.

await user.click(screen.getByRole('button', { name: /subscribe/i }));
expect(createSubscription).toHaveBeenCalledWith(
expect.objectContaining({ plan: 'pro', seats: 5 }),
);

Asserts the mock got called with certain arguments, which is internal wiring. It breaks the moment you rename a field, and says nothing about what the user saw: a green test here is compatible with a form that submits and renders a blank screen.

The brittle version reads as “my mock got called”; the durable version, as “the user saw their subscription go active.” One is a sentence about your plumbing, the other about your product. That difference holds for every entry below.

The consent gate is the cleanest first example, and its three beats recur in every component after it.

The trigger it meets: a critical UX path with legal weight. useConsent() gates your analytics on the visitor’s choice. One wrong line fires tracking before consent: a real privacy exposure, not a cosmetic bug. Too consequential for manual review, too fiddly for the end-to-end happy path to cover every branch.

The behaviors to assert. Phrase each as a sentence about the user, and the query falls out of it.

  • A first-time visitor with no stored consent sees the banner: findByRole('dialog', { name: /cookie/i }). (A non-modal strip has role region, not dialog. Query the role the component exposes; a failing query is the prompt to fix its semantics.)
  • Clicking Accept dismisses the banner and records consent: assert the banner is gone with queryByRole(...) and not.toBeInTheDocument(), plus that the consent setter was called with the granted value.
  • Clicking Reject records the rejected value and dismisses: the same shape with a different stored value.
  • A visitor whose consent is already stored never sees the banner on mount, again with the negative query.
  • The gate reads false before consent and true after, checked by rendering a tiny consumer of useConsent().

The render-interact-assert case this component is built around:

it('records consent and dismisses when the visitor accepts', async () => {
vi.mocked(setConsent).mockResolvedValue(undefined);
const { user } = render(<CookieConsent />);
expect(
await screen.findByRole('dialog', { name: /cookie/i }),
).toBeVisible();
await user.click(screen.getByRole('button', { name: /accept/i }));
expect(setConsent).toHaveBeenCalledWith('granted');
expect(screen.queryByRole('dialog', { name: /cookie/i })).not.toBeInTheDocument();
});

The cookie write is mocked at the import, the same pattern from the last section, just on a client cookie helper rather than next/headers. We say the write succeeds; we don’t test it.

it('records consent and dismisses when the visitor accepts', async () => {
vi.mocked(setConsent).mockResolvedValue(undefined);
const { user } = render(<CookieConsent />);
expect(
await screen.findByRole('dialog', { name: /cookie/i }),
).toBeVisible();
await user.click(screen.getByRole('button', { name: /accept/i }));
expect(setConsent).toHaveBeenCalledWith('granted');
expect(screen.queryByRole('dialog', { name: /cookie/i })).not.toBeInTheDocument();
});

Mount with no stored consent, so the banner has a reason to appear.

it('records consent and dismisses when the visitor accepts', async () => {
vi.mocked(setConsent).mockResolvedValue(undefined);
const { user } = render(<CookieConsent />);
expect(
await screen.findByRole('dialog', { name: /cookie/i }),
).toBeVisible();
await user.click(screen.getByRole('button', { name: /accept/i }));
expect(setConsent).toHaveBeenCalledWith('granted');
expect(screen.queryByRole('dialog', { name: /cookie/i })).not.toBeInTheDocument();
});

The user sees the banner. Use findBy, not getBy, because it may settle in asynchronously.

it('records consent and dismisses when the visitor accepts', async () => {
vi.mocked(setConsent).mockResolvedValue(undefined);
const { user } = render(<CookieConsent />);
expect(
await screen.findByRole('dialog', { name: /cookie/i }),
).toBeVisible();
await user.click(screen.getByRole('button', { name: /accept/i }));
expect(setConsent).toHaveBeenCalledWith('granted');
expect(screen.queryByRole('dialog', { name: /cookie/i })).not.toBeInTheDocument();
});

The user accepts.

it('records consent and dismisses when the visitor accepts', async () => {
vi.mocked(setConsent).mockResolvedValue(undefined);
const { user } = render(<CookieConsent />);
expect(
await screen.findByRole('dialog', { name: /cookie/i }),
).toBeVisible();
await user.click(screen.getByRole('button', { name: /accept/i }));
expect(setConsent).toHaveBeenCalledWith('granted');
expect(screen.queryByRole('dialog', { name: /cookie/i })).not.toBeInTheDocument();
});

The two consequences: the setter recorded 'granted', and the banner is gone. Use queryByRole for the absence assertion.

1 / 1

This test asserts on one mock call: setConsent was called with 'granted'. That looks like the smell the last section killed, but it isn’t. Recording consent is this component’s job and produces no separate rendered proof, so the call is the observable consequence. Assert the visible result when there is one; assert the call only when the call is the behavior the component owns.

Watch one trap. Consent lives in document.cookie (or the mocked store standing in for it), which survives between tests in the same file: a test that accepts consent leaves it accepted for the next, which then never sees its banner. Reset it in afterEach, the same reflex as the cleanup and vi.resetAllMocks() already in your setup.

Left to end-to-end. The component test proves the input flips: useConsent() reads true after the visitor accepts. Whether PostHog actually stops sending events before consent is the browser’s job. Same shape as the action/seam split: prove the input here, prove the wire fires there.

Component 2 — the multi-step subscribe form

Section titled “Component 2 — the multi-step subscribe form”

This is the component the action-mock pattern was built for.

The trigger it meets: a complex stateful interactive component. The subscribe form runs a state graph with more than three nodes: plan selection reveals or hides fields, validation gates the submit button, and the action resolves into one of two outcomes. Those transitions live entirely in the component, so the seam test never sees them.

The behaviors to assert.

  • The initial step renders only plan selection, so the seat-count field is absent: queryByRole(...) and not.toBeInTheDocument().
  • Selecting Pro reveals the seat-count field, and selecting Free hides it again: the branch the component test catches and the integration test structurally cannot.
  • Submit is disabled until required fields are filled, so toBeDisabled flips to toBeEnabled.
  • Submitting valid data drives the mocked action; assert the consequence, not the call. On Result.ok, the success state appears; on Result.err({ code: 'CARD_DECLINED' }), the error alert renders: findByRole('alert', { name: /card was declined/i }).
  • The submit button’s accessible name reflects the form’s state, like “Subscribe to Pro, 5 seats”: toHaveAccessibleName(...).

The form is wired with useActionState, its pending state flowing through useFormStatus into the submit button. Your test never reaches for those hooks; it asserts on what they render. Take the conditional branch first.

it('reveals the seat-count field only for the Pro plan', async () => {
const { user } = render(<SubscribeForm />);
expect(screen.queryByRole('spinbutton', { name: /seats/i })).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /pro/i }));
expect(await screen.findByRole('spinbutton', { name: /seats/i })).toBeVisible();
await user.click(screen.getByRole('radio', { name: /free/i }));
expect(screen.queryByRole('spinbutton', { name: /seats/i })).not.toBeInTheDocument();
});

Before any choice, the seat-count field isn’t in the document. The negative queryByRole pins the start node.

it('reveals the seat-count field only for the Pro plan', async () => {
const { user } = render(<SubscribeForm />);
expect(screen.queryByRole('spinbutton', { name: /seats/i })).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /pro/i }));
expect(await screen.findByRole('spinbutton', { name: /seats/i })).toBeVisible();
await user.click(screen.getByRole('radio', { name: /free/i }));
expect(screen.queryByRole('spinbutton', { name: /seats/i })).not.toBeInTheDocument();
});

Choosing Pro reveals the field, with findByRole because it settles in after the click. The seam test never traverses this edge, because the action never sees the plan toggle.

it('reveals the seat-count field only for the Pro plan', async () => {
const { user } = render(<SubscribeForm />);
expect(screen.queryByRole('spinbutton', { name: /seats/i })).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: /pro/i }));
expect(await screen.findByRole('spinbutton', { name: /seats/i })).toBeVisible();
await user.click(screen.getByRole('radio', { name: /free/i }));
expect(screen.queryByRole('spinbutton', { name: /seats/i })).not.toBeInTheDocument();
});

Switching to Free hides it again, proving the transition runs both ways.

1 / 1

Now the two outcomes of submitting, side by side: same form, two action contracts, two things the user sees.

vi.mocked(createSubscription).mockResolvedValue(Result.ok({ subscriptionId: 'sub_123' }));
const { user } = render(<SubscribeForm />);
await fillValidSubscription(user);
await user.click(screen.getByRole('button', { name: /subscribe to pro/i }));
expect(await screen.findByRole('status', { name: /subscription active/i })).toBeVisible();

The action resolves ok, so the form shows its success state: a live status region announcing the subscription is active. Assert only what the user sees.

The form has one job in both cases: take the action’s Result and turn it into something the user sees. That job lives in the component, so it’s tested there. What the action does to produce ok versus err, charging the card, writing the row, logging the audit, is never in frame.

The form’s state graph:

%%{init: {'themeCSS': '.nodeLabel, .nodeLabel * { font-size: 15px !important; } .edgeLabel, .edgeLabel * { font-size: 12.5px !important; }'} }%%
flowchart LR
  start(["Form mounts"])
  plan{"Plan?"}
  pro["Pro: seat field shown"]
  free["Free: no seat field"]
  ready["Submit enabled"]
  submit{"Action resolves"}
  okState(["Success status"])
  errState(["Error alert"])

  start --> plan
  plan -- "Pro — findByRole('spinbutton')" --> pro
  plan -- "Free — queryByRole … not in document" --> free
  pro -- "fields valid — toBeEnabled" --> ready
  free --> ready
  ready -- click --> submit
  submit -- "ok — findByRole('status', …)" --> okState
  submit -- "err — findByRole('alert', …)" --> errState

  class start,ready entry
  class plan,submit gate
  class pro,free reveal
  class okState ok
  class errState err

  classDef entry fill:#1f2937,stroke:#94a3b8,color:#f8fafc
  classDef gate fill:#dbeafe,stroke:#1d4ed8,color:#111,stroke-width:2px
  classDef reveal fill:#ede9fe,stroke:#7c3aed,color:#111,stroke-width:2px
  classDef ok fill:#bbf7d0,stroke:#15803d,color:#111,stroke-width:2px
  classDef err fill:#fecaca,stroke:#b91c1c,color:#111,stroke-width:2px
Every edge is a behavior the component test owns and a branch the seam test never traverses.

Left to the seam (last chapter): the database write, the audit entry, the Stripe call. Left to end-to-end (next chapter): the full Stripe Checkout redirect after the form succeeds. The component test stops at the edge of the graph; everything past Submit is someone else’s test.

The trigger it meets: a shared component library. This picker is consumed across Reports, Invoices, and Filters, so one bug ships as three regressions in three features. A single test on a shared primitive pays for itself many times over.

The behaviors to assert.

  • It renders its default range in the user’s locale: render with a locale and assert on the formatted date text the user reads.
  • Selecting a start date past the current end date snaps the end date forward, coordinating the two ends of the range.
  • Keyboard navigation moves the focused day: await user.keyboard('{ArrowRight}'), then assert with toHaveFocus().
  • Esc closes the popover and returns focus to the trigger: assert toHaveFocus() on the trigger after close, an accessibility behavior manual testing skips.
  • Selecting a range updates the displayed range: assert the user-visible result.

A picker that reads the real clock is flaky by construction: “this month” changes with the day CI runs. Pin time with vi.setSystemTime(new Date('2026-05-14')), the clock seam , and “today” is the same date every run.

The focus walk in full:

it('moves focus by keyboard and returns it to the trigger on close', async () => {
vi.setSystemTime(new Date('2026-05-14'));
const { user } = render(<DateRangePicker />);
const trigger = screen.getByRole('button', { name: /select dates/i });
await user.click(trigger);
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('button', { name: /15 may/i })).toHaveFocus();
await user.keyboard('{Escape}');
expect(trigger).toHaveFocus();
});

Pin “today” so the grid is deterministic: “15 May” is a stable target only when today is fixed.

it('moves focus by keyboard and returns it to the trigger on close', async () => {
vi.setSystemTime(new Date('2026-05-14'));
const { user } = render(<DateRangePicker />);
const trigger = screen.getByRole('button', { name: /select dates/i });
await user.click(trigger);
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('button', { name: /15 may/i })).toHaveFocus();
await user.keyboard('{Escape}');
expect(trigger).toHaveFocus();
});

Grab the trigger by role and accessible name, then click to open the popover.

it('moves focus by keyboard and returns it to the trigger on close', async () => {
vi.setSystemTime(new Date('2026-05-14'));
const { user } = render(<DateRangePicker />);
const trigger = screen.getByRole('button', { name: /select dates/i });
await user.click(trigger);
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('button', { name: /15 may/i })).toHaveFocus();
await user.keyboard('{Escape}');
expect(trigger).toHaveFocus();
});

The arrow key moves focus one day forward, so assert “15 May” now holds focus. Focus management lives in the component, so this test owns it.

it('moves focus by keyboard and returns it to the trigger on close', async () => {
vi.setSystemTime(new Date('2026-05-14'));
const { user } = render(<DateRangePicker />);
const trigger = screen.getByRole('button', { name: /select dates/i });
await user.click(trigger);
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('button', { name: /15 may/i })).toHaveFocus();
await user.keyboard('{Escape}');
expect(trigger).toHaveFocus();
});

Escape closes the popover and sends focus back to the trigger rather than off into the void, the behavior a keyboard user feels the instant it breaks.

1 / 1

Only the component test can hold this: no other layer sees focus move. The locale-aware render puts the render helper’s locale option to work:

DateRangePicker.test.tsx
import esMessages from '@/messages/es-ES.json';
vi.setSystemTime(new Date('2026-05-14'));
render(<DateRangePicker />, { locale: 'es-ES', messages: esMessages });
// The Spanish month, lowercased, no comma — the locale read all the way through:
expect(screen.getByText('14 may 2026')).toBeVisible();

Left out entirely. The calendar’s internal cell layout, which element wraps which day, is the library’s business. Assert that the picker behaves; never assert on its internals, the same carve-out the ladder lesson drew around third-party widgets.

Component 4 — the data table with row selection

Section titled “Component 4 — the data table with row selection”

The trigger it meets: a shared component library. A <DataTable> backs every list surface in the app: invoices, customers, exports. Its selection state and the toolbar that reacts to it behave the same wherever the table appears, so one test protects all those surfaces at once.

The behaviors to assert.

  • Rows render with the expected accessible names: getAllByRole('row') for the count, plus per-row content. Address a row by what it contains, not by its array index.
  • Clicking a row’s checkbox selects it and updates the toolbar count to “1 selected”: scope the checkbox with within(row).getByRole('checkbox'), then assert the toolbar text.
  • The header checkbox toggles all rows, select-all and deselect-all.
  • Selecting rows enables the Delete button, so toBeDisabled flips to toBeEnabled.
  • Clicking Delete with two rows selected opens a confirm dialog whose name carries the count: getByRole('dialog', { name: /delete 2 invoices/i }). The count is computed from selection state, a dynamic accessible name.

The walkthrough below is where within scoping and that dynamic dialog name do their work.

it('confirms deletion with the selected count in the dialog name', async () => {
vi.mocked(deleteInvoices).mockResolvedValue(Result.ok({ deleted: 2 }));
const { user } = render(<InvoicesTable rows={twoInvoices} />);
for (const row of screen.getAllByRole('row').slice(1, 3)) {
await user.click(within(row).getByRole('checkbox'));
}
expect(screen.getByText('2 selected')).toBeVisible();
const remove = screen.getByRole('button', { name: /delete/i });
expect(remove).toBeEnabled();
await user.click(remove);
expect(screen.getByRole('dialog', { name: /delete 2 invoices/i })).toBeVisible();
});

The delete action is mocked at the import, the same pattern as the subscribe form. We say only “the delete succeeds”; the database effect is never in frame.

it('confirms deletion with the selected count in the dialog name', async () => {
vi.mocked(deleteInvoices).mockResolvedValue(Result.ok({ deleted: 2 }));
const { user } = render(<InvoicesTable rows={twoInvoices} />);
for (const row of screen.getAllByRole('row').slice(1, 3)) {
await user.click(within(row).getByRole('checkbox'));
}
expect(screen.getByText('2 selected')).toBeVisible();
const remove = screen.getByRole('button', { name: /delete/i });
expect(remove).toBeEnabled();
await user.click(remove);
expect(screen.getByRole('dialog', { name: /delete 2 invoices/i })).toBeVisible();
});

within(row) scopes the checkbox query to that row, so each click ticks the right box rather than the first on the page. The slice(1, 3) skips the header row.

it('confirms deletion with the selected count in the dialog name', async () => {
vi.mocked(deleteInvoices).mockResolvedValue(Result.ok({ deleted: 2 }));
const { user } = render(<InvoicesTable rows={twoInvoices} />);
for (const row of screen.getAllByRole('row').slice(1, 3)) {
await user.click(within(row).getByRole('checkbox'));
}
expect(screen.getByText('2 selected')).toBeVisible();
const remove = screen.getByRole('button', { name: /delete/i });
expect(remove).toBeEnabled();
await user.click(remove);
expect(screen.getByRole('dialog', { name: /delete 2 invoices/i })).toBeVisible();
});

The toolbar count is the user-observable proof that two rows are selected.

it('confirms deletion with the selected count in the dialog name', async () => {
vi.mocked(deleteInvoices).mockResolvedValue(Result.ok({ deleted: 2 }));
const { user } = render(<InvoicesTable rows={twoInvoices} />);
for (const row of screen.getAllByRole('row').slice(1, 3)) {
await user.click(within(row).getByRole('checkbox'));
}
expect(screen.getByText('2 selected')).toBeVisible();
const remove = screen.getByRole('button', { name: /delete/i });
expect(remove).toBeEnabled();
await user.click(remove);
expect(screen.getByRole('dialog', { name: /delete 2 invoices/i })).toBeVisible();
});

Selecting rows enabled the Delete button, so toBeDisabled has flipped to toBeEnabled, and the user clicks it.

it('confirms deletion with the selected count in the dialog name', async () => {
vi.mocked(deleteInvoices).mockResolvedValue(Result.ok({ deleted: 2 }));
const { user } = render(<InvoicesTable rows={twoInvoices} />);
for (const row of screen.getAllByRole('row').slice(1, 3)) {
await user.click(within(row).getByRole('checkbox'));
}
expect(screen.getByText('2 selected')).toBeVisible();
const remove = screen.getByRole('button', { name: /delete/i });
expect(remove).toBeEnabled();
await user.click(remove);
expect(screen.getByRole('dialog', { name: /delete 2 invoices/i })).toBeVisible();
});

The dialog’s accessible name carries the live count, “Delete 2 invoices”, proving it is wired to selection state.

1 / 1

Leave the header-checkbox and row-render assertions as one-liners; the flow above already carries their shape.

Left out. Pagination is URL-state, so it’s integration territory with its own test elsewhere. The table’s virtualization belongs to the library. Assert the visible rows and the selection behavior, never the virtualizer.

This is the purest component test in the catalog: props in, rendered content out, no interactions, mocks, or async.

The trigger it meets: a critical UX path on the money surface. Before a user commits money, they read the total, the tax, the discount, and the trial-end date, each a sentence that deserves an assertion. Too consequential for manual review, and its content variants (coupon or not, trial or not, one locale or another) are too granular for the end-to-end happy path, which exercises exactly one.

The behaviors to assert, a matrix of prop variants mapped to rendered content, one it per variant:

  • plan: 'pro', seats: 5, couponCode: undefined → the total line reads the expected amount.
  • A coupon applied → the discount line appears and the total updates. The happy-path end-to-end test never exercises this variant.
  • The trial-end date renders in the user’s locale and timezone: the render helper’s locale option and the project’s date formatter at work.
  • “Subtotal”, “Tax”, and “Total” labels are present and announced.

Two contrasting variants make the matrix concrete: the plain case, and the coupon case end-to-end never reaches.

it('shows the plan total with no discount line when there is no coupon', () => {
render(<CheckoutSummary plan="pro" seats={5} couponCode={undefined} />);
expect(screen.getByText('Total')).toBeVisible();
expect(screen.getByText('$250.00')).toBeVisible();
expect(screen.queryByText(/discount/i)).not.toBeInTheDocument();
});
it('shows the discount line and an updated total when a coupon applies', () => {
render(<CheckoutSummary plan="pro" seats={5} couponCode="LAUNCH20" />);
expect(screen.getByText(/discount/i)).toBeVisible();
expect(screen.getByText('$200.00')).toBeVisible();
});

With no coupon, the total reads the full $250.00 and no discount line shows. The negative queryByText pins that absence, since a leaked discount row on a money surface is a real bug.

it('shows the plan total with no discount line when there is no coupon', () => {
render(<CheckoutSummary plan="pro" seats={5} couponCode={undefined} />);
expect(screen.getByText('Total')).toBeVisible();
expect(screen.getByText('$250.00')).toBeVisible();
expect(screen.queryByText(/discount/i)).not.toBeInTheDocument();
});
it('shows the discount line and an updated total when a coupon applies', () => {
render(<CheckoutSummary plan="pro" seats={5} couponCode="LAUNCH20" />);
expect(screen.getByText(/discount/i)).toBeVisible();
expect(screen.getByText('$200.00')).toBeVisible();
});

Change one prop, couponCode="LAUNCH20", and a discount line appears as the total drops to $200.00. That’s the matrix: one it per variant, each asserting the content it produces.

1 / 1

This test needs no mocks beyond the locale provider the render helper supplies. One boundary is worth naming: the summary test asserts that the line renders the right value for its props, but how that number is computed is a pure unit test in /lib. Same rule as the form and the seam: test each thing once, at the layer that owns it.

On a presentational component like this, the reflex is to reach for toMatchSnapshot() and call it covered. Resist it.

it('renders the checkout summary', () => {
const { container } = render(<CheckoutSummary plan="pro" seats={5} />);
expect(container).toMatchSnapshot();
});

Re-asserts the whole rendered tree byte-for-byte. It fails on every copy tweak, spacing change, and class rename, so the team updates it on reflex, and a test you update on reflex catches nothing. It never tells you which number mattered. Churn without signal.

On a money surface, “the amount is right” is the whole point, so assert the amount, by name.

The catalog’s real value is in what it leaves out. On the same app, the components that don’t make the list and why:

  • Every <Card>, <Section>, and <PageHeader> meets no trigger. With no state and no branching content, testing them is the coverage theatre the first lesson warned about: green checkmarks that catch nothing.
  • The Stripe-redirect button on the pricing page is covered end-to-end next chapter. A component test here would duplicate that at higher cost and lower fidelity.
  • The page-level Server Components are framework-orchestrated, never a Testing Library surface. Test them at the seam and on the money path, not with render.
  • The Server Actions themselves are covered at the seam last chapter. You mock them here; you don’t test them here.

Five reflexes for reviewing a new component test on the next pull request:

Which trigger does this meet? Name it in the pull request: “shared library”, “complex state”, or “critical UX”. If no trigger fits, that’s a request to delete the test.
Are the queries role-first? A data-testid needs a one-line justification, or it’s a smell.
Does it assert what the user observes (rendered content, accessible names, focus) rather than internal state, props, or class names?
Is every Server Action mocked at the import, and does its real test exist at the seam?
Does it pass under vitest --project component --sequence.shuffle.files? A test that depends on order is a leaked-state bug.

The most transferable skill in this chapter: given a behavior, name the layer that owns it. Component test? Seam test? End-to-end? Unit test? Or no test at all.

A subscribe-form component test mounts the form, fills it in, clicks Subscribe, and its only assertion is:

expect(createSubscription).toHaveBeenCalledWith({ plan: 'pro', seats: 5 });

The test passes. What’s the problem with it?

It checks that the mocked action was called, but never checks what the form did with the result — so it stays green even if the form then renders nothing and the user is left staring at a dead screen.

Calling a real Server Action from jsdom hits the database, so this assertion can never pass in a component test.

It stops too early — it should go on to read back the subscription row to confirm the write actually landed.

Nothing — pinning the exact arguments passed to the action is the tightest possible test of a form.

Now the main drill.

Sort each behavior into the test layer that owns it — or into 'don't test' if no trigger is met. Drag each item into the bucket it belongs to, then press Check.

Component test Observable client behavior
Seam / integration test Server Action effect
End-to-end Money path in a real browser
Don't test / delete No trigger met, or wrong surface
The seat-count field appears when Pro is selected
The confirm dialog’s name shows the count of selected rows
Esc closes the date picker and returns focus to the trigger
The checkout summary shows a discount line when a coupon is applied
createSubscription writes the subscription row and revalidates the path
deleteInvoice refuses a member who lacks the admin role
The full Stripe Checkout redirect completes after Subscribe
PostHog actually stops firing events before the visitor consents
The <Card> component renders its children
The page-level Server Component fetches and lists invoices

Where a behavior gets tested is the question an experienced engineer answers in a second and a junior re-litigates every time.

Two reflexes underneath the catalog go deeper here: querying the way a user reaches the screen, and replacing a module at its import so a component test stops at its own layer.