skip to content

If a component test should assert only what a user can perceive, how do you test a component whose job is to invoke an `onSubmit` prop and send a request — outputs a user never sees on screen?

level: middleimportance: should knowfreq 58%

answer

  1. observable outside, not just visible
  2. callbacks and requests are outward effects
  3. drive by interaction, assert at boundary
  4. never invoke the handler directly
  5. payload, call count, and the negative case

basics

~20 s

A component's observable contract is wider than its pixels: it includes the callbacks it invokes and the requests it sends. Assert those at the component's outer boundary, driven by a real user interaction — never by reaching into internal handlers.

solid answer

~50 s

"Behavior, not implementation" is often misread as "only assert pixels", but the real line is between what is observable from *outside* the component and what happens *inside* it. A callback prop is supplied by the caller, so the caller can legitimately observe it; a network request crosses the component's boundary into the outside world. Both are outward effects and both are fair game. What matters is *how* you get there. Drive the assertion from a real interaction — fill the fields the way a user would, click the button by its visible label — and then check the effect at the boundary: the callback was invoked once, with the payload built from what the user typed; or the request went out with that body. What I would not do is call the handler directly, or assert which internal function ran, or check that state updated on the way. Those are the mechanism, and they'd break the moment the component is rewritten.

code

javascript · 15 lines
javascript
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import NewsletterForm from './NewsletterForm';

test('submits the typed address exactly once', async () => {
  const onSubmit = jest.fn();
  render(<NewsletterForm onSubmit={onSubmit} />);

  await userEvent.type(screen.getByLabelText('Email'), '[email protected]');
  await userEvent.click(screen.getByRole('button', { name: 'Subscribe' }));

  expect(onSubmit).toHaveBeenCalledTimes(1);
  expect(onSubmit).toHaveBeenCalledWith({ email: '[email protected]' });
});

go deeper

for a junior

Know that a component's job can include calling back to its parent, and that you test it by interacting with the rendered form and checking the callback received what the user typed.

for a middle

Explain the observable-from-outside line, and be ready to say why invoking the handler directly is a weaker test than clicking the button that should be wired to it.

for a senior

Show judgment about which boundary to assert at when a component both delegates to props and talks to the network, and argue for the negative and call-count assertions that catch real defects.

for a principal

Own where the seams belong across a codebase — which layer of the system is responsible for verifying a payload — so the same behavior is not asserted redundantly at three levels.

## Restating the rule correctly The slogan "test behavior, not implementation" gets compressed into "only assert what renders", and that compression is wrong. The precise line is **observable from outside the component** versus **internal to it**. Rendered output is the most common outward observation, but it is not the only one. A component has three kinds of outward effect: 1. **What it renders** — text, controls, states a user perceives. 2. **What it calls** — callback props the parent handed it (`onSubmit`, `onChange`, `onClose`), navigation it triggers. 3. **What it sends** — requests that leave the process entirely. All three are visible to somebody outside the component: the user, the parent, the server. All three are legitimately assertable. What is *not* assertable is the path between the interaction and the effect — which internal handler ran, what state was set on the way, how many renders it took. ## Drive from the interaction, assert at the boundary The shape of a good test for an outward effect is always the same: - **Arrange** — render the component with a spy in place of the real callback, or with the network boundary faked. - **Act** — do what a user does. Type into the field found by its label, click the button found by its visible name. Not: grab the form node and dispatch a raw submit; not: call the prop yourself. - **Assert** — the callback was invoked, once, with the payload derived from what the user typed. Or: a request was sent with that body. That structure is what makes the assertion refactor-proof. The component can move from a controlled input to an uncontrolled one, from a hand-rolled form to a form library, from one state container to another — the test still passes, because the entry point (a user typing and clicking) and the exit point (a callback carrying the typed values) are both unchanged. ```javascript const onSubmit = jest.fn(); render(<NewsletterForm onSubmit={onSubmit} />); await userEvent.type(screen.getByLabelText('Email'), '[email protected]'); await userEvent.click(screen.getByRole('button', { name: 'Subscribe' })); expect(onSubmit).toHaveBeenCalledTimes(1); expect(onSubmit).toHaveBeenCalledWith({ email: '[email protected]' }); ``` Every element of that test corresponds to something real: a user with an email address, a button they can see, a parent that receives the value. ## Why calling the prop directly is the wrong shortcut It is always tempting to skip the typing and clicking and just invoke the handler. Two things break. First, you stop testing the wiring. If the button is never connected to the handler, if it is `disabled` when it should not be, if the form swallows the submit — the direct-invocation test is green and the product is broken. The interaction *is* part of the behavior. Second, you couple to the handler's identity and signature, which is exactly the internal shape most likely to change during a refactor. ## Asserting the call, not just that it happened "It was called" is a weak assertion. Three properties are usually worth pinning down, because each corresponds to something a user would notice: - **The payload** — the values the user actually entered, in the shape the parent expects. A wrong payload means their data is lost. - **The call count** — exactly once. A double-fire on submit is a real, user-visible defect (duplicate orders), and counting the calls is not a render-count assertion; it is counting an outward effect. - **The absence of a call** — when validation fails, nothing should be submitted. That negative assertion is often the more valuable one. ## Where the request boundary fits When the component sends the request itself rather than delegating to a prop, the same principle applies one layer out: the seam belongs at the network boundary, and the assertion is about what crossed it. That keeps the test indifferent to whether the component uses one HTTP client or another, or restructures how it builds the call. Note the direction of the tradeoff — a callback prop is the cheapest, most stable boundary available, so when a component already delegates to a prop, assert there rather than reaching further out. ## The mistake to name in an interview The two failure modes sit on opposite sides. One candidate refuses to assert anything but rendered text and therefore never verifies that the form submits at all. The other asserts internal handlers and state updates and calls it "testing the submit logic". The correct position is in between and it is precise: **drive the component the way a user does; observe it the way its collaborators do; never observe it the way its own source code does.**

  • Why is asserting the callback was invoked exactly once often more valuable than asserting it was invoked at all?
    Because double-submission is a real, user-facing defect — duplicate orders, duplicate emails — and it is easy to introduce: a stray extra listener, a form that both submits and calls the handler, a missing guard while a request is in flight. "Was called" passes in every one of those cases. The count is what catches them, and it is an outward effect, not an internal render count.
  • A component both calls an onSave prop and sends a request itself. Where should the test's assertion live?
    At whichever boundary the component actually owns. If the parent supplies `onSave` and the parent does the sending, assert on the prop — it's the cheapest and most stable seam. If the component sends the request itself, fake the network boundary and assert on what crossed it. Asserting at both for the same behavior usually means one of the two assertions is redundant and will need updating twice.
  • Is asserting that validation blocked a submit a behavior assertion or an implementation one?
    Behavior, on both halves. The user perceives the error message that appears, and the absence of a submit is an observable outward effect — nothing left the component. So the test drives an invalid entry, asserts the message the user reads, and asserts the callback was not invoked. Nothing there depends on how validation is implemented.

saying these in an interview costs you the question

  • "Behavior means only what's rendered, so don't assert callbacks"
  • Calling the submit handler directly instead of clicking the button
  • Asserting the callback fired without checking the payload
  • Treating a request assertion as inherently implementation-coupled
  • Never testing the negative case where submission should be blocked

context