Component Testing
Given a rendered component, when the user interacts, then visible behaviour changes. Querying by role and accessible name is both better practice and an accessibility check you get for free.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
How do I test what a component does for a person, without testing how it happens to be implemented?
Someone clicks Save on a form and expects one of two things: the thing is saved and they are told so, or it is not and they are told why, in a place they can find.
Mount the component, reach in and check its internal state after the click, and add a snapshot of the rendered markup so any change gets noticed. Query elements by a data-testid because that will not change when the design does.
The state assertion passes while the screen is wrong. isSaving flipped to true and no spinner rendered, because the branch that renders it was deleted; the test never looked at the output.
- The state assertion passes while the screen is wrong.
isSavingflipped totrueand no spinner rendered, because the branch that renders it was deleted; the test never looked at the output. - A refactor from one state container to another turns dozens of tests red without a single change to behaviour. The tests were coupled to a decision the user cannot perceive.
- The snapshot updates automatically for the whole team, forever. Once a diff is 200 lines nobody reads it, and the accepted diff is where the accessible name was silently lost.
- Test ids are invisible to a person and to a screen reader. A test that finds
submit-btnwill keep passing after the element becomes adivwith no role, no keyboard behaviour and no accessible name (Div Soup: How It Happens and What It Costs). - Because the test drives the component through its props rather than through events, it proves the component responds to the props it was given — a fact that was never in doubt.
What is actually happening
In the browser, not in the framework.
- A component test renders into a real DOM — a simulated document in a runner, or a real one in a browser-based runner — and then dispatches events into it. The component runs, its framework schedules, and the DOM changes (How an Event Is Dispatched).
- Assertions read that DOM back. The useful ones read it the way a person or an assistive technology does: what is present, what is visible, what is named, what is disabled, what is announced (The Accessibility Tree).
- Role and accessible name are computed by the browser from element type, attributes and content. Querying by them means the query exercises the same computation a screen reader depends on (Semantics Before ARIA).
- User interaction is a sequence, not an event. A real click is pointer down, focus change, pointer up, click; a real typing action is a series of key events with the value changing between them. Libraries that simulate the sequence catch bugs that a single synthetic
clickdoes not (Pointer Events). - Framework updates are asynchronous in most modern frameworks, so an assertion made immediately after an interaction reads a DOM that has not been updated yet. The right response is to wait for a *condition*, never for a duration (The Microtask Checkpoint).
- In a simulated document there is no layout and no paint. Visibility is decided by attributes and inline styles, not by geometry, which is precisely the boundary of what this level can prove (The Rendering Pipeline).
What this makes the browser do
And which of it is avoidable.
- Building and tearing down a DOM tree per test, plus the framework's own mount and unmount work. This is why hundreds of component tests cost real time even without a browser.
- In a simulated DOM: no style resolution worth the name, no layout, no paint, no compositing.
getBoundingClientRectreturns zeros andIntersectionObservernever fires unless you provide it (Layout Thrashing). - In a real browser: the whole pipeline per test, which buys geometry, real focus behaviour, real scrolling and real CSS at a large multiple of the cost.
- Leaked work between tests — timers, listeners, observers, pending requests — is browser work your next test inherits, and the usual reason a suite passes alone and fails together (Memory Leaks).
Given, when, then — in the user's vocabulary
The discipline is to write the "then" before the "when". If the sentence you can write is "then isSubmitting is true", you are about to test an implementation detail. If it is "then the Save button is disabled and a saving message is announced", the test will survive every refactor that keeps that true.
Notice what the second version below is buying. It fails if the button stops being a button, if its accessible name changes, if the disabled state stops being conveyed, if the status message disappears, and if focus does not return. None of those are things the test explicitly went looking for; they come from asserting the way a person perceives the component.
1// Implementation-shaped: passes while the screen is wrong.2test('sets submitting state', () => {3 const { result } = renderHook(() => useSaveForm())4 act(() => result.current.submit())5 expect(result.current.isSubmitting).toBe(true) // nothing rendered6 expect(saveSpy).toHaveBeenCalledTimes(1) // nothing observed7})8 9// Behaviour-shaped: fails for the reasons a user would notice.10test('saving a valid form tells the user it worked', async () => {11 const user = userEvent.setup()12 render(<SaveForm onSave={fakeSave} />)13 14 // given15 await user.type(screen.getByLabelText('Order reference'), 'A-1042')16 17 // when — a real interaction, not a handler call18 await user.click(screen.getByRole('button', { name: 'Save' }))19 20 // then — what the person sees, in the order they see it21 expect(await screen.findByRole('status')).toHaveTextContent(/saving/i)22 expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()23 expect(await screen.findByText('Order A-1042 saved')).toBeVisible()24 expect(screen.getByRole('button', { name: 'Save' })).toHaveFocus()25})26 27// And the failure case, which is the one that ships broken:28test('a rejected save keeps the text and explains why', async () => {29 const user = userEvent.setup()30 render(<SaveForm onSave={() => Promise.reject(new Error('Reference already used'))} />)31 await user.type(screen.getByLabelText('Order reference'), 'A-1042')32 await user.click(screen.getByRole('button', { name: 'Save' }))33 34 const field = screen.getByLabelText('Order reference')35 expect(field).toHaveValue('A-1042') // the work is not thrown away36 expect(field).toHaveAccessibleDescription(/already used/i)37 expect(field).toHaveFocus() // and they can fix it38})The third test is the one teams skip and users hit. A save that fails and silently discards the typed value is a support ticket; a save that fails, keeps the value, says why and puts focus back on the field is a minor annoyance.
Querying by role and name is an accessibility test
This is the argument worth making explicitly, because it converts an accessibility obligation into a testing convenience. To query by role and accessible name, the element must have a role and an accessible name — which means the query is only writable if the component is already operable by assistive technology. The test cannot be written against an inaccessible component, and it breaks the moment the component becomes one.
A test id has the opposite property. It is an attribute that exists only for the test, invisible to every user, and completely indifferent to whether the element is a button, a link or a div with a click handler. The test that used it will pass on the day the component becomes unusable by keyboard.
The spec below is what a query by role and name is implicitly asserting on every run. You do not have to write these assertions; you get them by choosing the query.
semantics A real button element (implicit role button), named by its visible text, with disabled or aria-disabled conveying the busy state and aria-describedby associating any error.
| Tab | Reaches the control in document order. A div with a click handler is skipped entirely, and the query that found it by role would have already failed. |
| Enter | Activates the button — a native behaviour you inherit and a custom element has to re-implement. |
| Space | Also activates a button. A link-shaped control activates on Enter only, which is why the chosen element type is a behavioural decision, not a styling one. |
| Shift+Tab | Returns to the previous control, which is how a test can assert focus order rather than merely focus presence. |
- — The control is focusable without a
tabindexhack, because it is a real button. - — After a successful save, focus is somewhere intentional — commonly back on the control, or on the status region if the view changed.
- — After a rejected save, focus is on the first invalid field so the problem can be fixed without hunting.
- — The busy state, via a status region rather than by only changing the button label.
- — The outcome — saved, or the specific reason it was not — as text inside a region with a status or alert role (Live Regions and Announcement).
- — The association between an error and its field, so the reason is read when the field is focused rather than only floating nearby.
usually broken by The pattern invites an aria-label added purely to make a query pass. That changes what a screen reader announces in order to satisfy a test runner, and it usually replaces a perfectly good visible name with a slightly different invisible one — leaving voice-control users unable to say what they can see (The Rules of ARIA).
What makes a component suite brittle
Brittleness is not a property of component tests; it is a property of tests that observe things a user cannot. Every row below is the same mistake in a different costume: the assertion is attached to a decision rather than to an outcome.
The snapshot row deserves its own note. A snapshot is not wrong in principle — for a small, deliberate, human-readable output it can be an excellent test. It goes wrong at scale: once the diff is longer than a screen, the review becomes an approval, and the test has inverted from a guard into a rubber stamp.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A state container is swapped out | Dozens of tests red, no behaviour changed | Assertions read internal state instead of rendered output | Re-express each "then" as something visible, then delete the state assertions entirely. |
| A wrapper element is added for layout | Every snapshot in the module is stale | The snapshot captures structure, and structure is not the contract | Keep snapshots for small, intentional outputs; assert on roles, names and text for everything else. |
A button becomes a styled div | Suite stays green, keyboard users cannot use the feature | Queries used test ids, which survive the loss of semantics | Query by role and name so the semantics are load-bearing for the test (Semantics Are Behaviour). |
| CI machine under load | Intermittent failures near any async update | The test waited for a duration rather than for a condition | Replace every fixed wait with a query that retries until the condition holds. |
| A child component is mocked away | Parent test green, the pair broken in production | The mock removed exactly the integration under test | Mock the network and the clock; keep your own components real. |
| Tests run in a different order or sharded | Failures that disappear when run alone | Module-level state, uncleaned timers or a shared fake server leaked between tests | Reset state per test and fail loudly on work outstanding at teardown (Memory Leaks). |
| A visible label is reworded | A test fails for a correct change | The accessible name is genuinely part of the contract | Update it deliberately — this is the one row where the brittleness is the feature. |
How to build it
Most important first.
- Write every test as given / when / then: given this rendered state, when the user does this, then this visible thing changes. If you cannot phrase the "then" in terms a user could observe, you are testing implementation.
- Query by role and accessible name first, then by label, then by text. Reserve a test id for the genuinely unnameable — a chart canvas, a decorative container (The Rules of ARIA).
- Drive the component with real interaction: click the button, type into the field, press Tab, press Escape. Do not call the handler prop directly and do not set state from the test.
- Assert on the output the user gets: text that appears, a control that becomes disabled, an error associated with a field, focus landing somewhere sensible (Errors People Can Actually Perceive).
- Wait for conditions, not durations.
findBya queryable state; never sleep for a fixed time (End-to-End Testing covers why the same rule matters more one level up). - Mock at the boundary of the system, not inside your own code: stub the network, keep your reducers, formatters and child components real (The Life of a Fetch).
- Test the states that exist in production and not in Storybook: loading, empty, error, partial, too-long text, and the state after a failed retry (Loading, Error, Empty — The States You Did Not Render).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- This is the level where accessibility is cheapest to catch and cheapest to fix, because a failure points at one component rather than at a page.
- A query by role and name is a real assertion about the accessibility tree. When someone replaces a
buttonwith a styleddiv, or removes the visible text that was serving as the accessible name, the query stops finding it — which is the test failing for exactly the right reason (Keyboard Operability). - Test focus explicitly: after opening a dialog, focus should be inside it; after closing, it should return to the control that opened it; after a validation failure, it should be on the first invalid field (Focus Management).
- Test announcement where it exists: an error summary, a status message, a live region updating after a save. Assert that the text is present in the region and that the region has the role, not merely that the text appears somewhere (Live Regions and Announcement).
- Automated rule checks belong here too. Running them per component in the states the component actually has — including the error state — finds far more than running them once on a rendered page (Accessibility Testing).
What can go wrong
- Asserting on internal state or on render counts. The test tracks a decision the user cannot perceive, so it fails on refactors and passes on regressions.
- Snapshot tests that nobody reads. Their signal decays to zero within weeks, and their update command makes accepting a real regression a single keystroke.
- Test ids as the default query. They mask exactly the failure this level is best placed to catch: an element that stopped being a button.
- Mocking a child component to make a parent test simpler, which removes the integration the test was there to prove.
- The mitigation failing: role-and-name queries that people escape by adding
aria-labelpurely to make a test pass, which changes what a screen reader announces to satisfy a runner. - Fixed waits sprinkled to fix flake. They convert an intermittent failure into a slow suite that still fails on a loaded machine.
- Tests that share module-level state or a fake server between files, so results depend on execution order.
- A test that types and then immediately asserts is racing the framework's update. It passes on a fast machine and fails on a loaded one, and the fix is a condition-based query rather than a delay.
- Two overlapping requests started by a component can resolve in either order. Testing that the slower first request does not overwrite the newer result requires deliberately resolving them out of order (Out-of-Order Responses).
- Timers, observers and pending fetches that survive teardown fire during the next test, producing failures attributed to entirely innocent code.
- Escaping is provable at this level. Render a value containing markup and assert that it appears as text, not as elements; do the same for a URL field with a
javascript:scheme (Cross-Site Scripting). - Any deliberate raw-HTML sink deserves a permanent test with a hostile input, because that call is the one place the framework's escaping has been switched off (Sanitization and Trusted HTML).
- A test showing that a control is hidden for a role proves the UI hides it. It proves nothing about whether the action is refused, which is a server-side property (Authorization-Aware UI).
- Fixtures for these tests routinely contain copied production payloads. Strip them: a fixture is a committed, greppable, permanent copy of whatever was in it.
- "Snapshots give us coverage of the markup." They give you a record of the markup. Coverage implies someone decided the markup was right, and after the third auto-update nobody has.
- "Test ids decouple tests from the DOM." They decouple tests from the *semantics*, which is the part users depend on. They stay stable while the component quietly stops being operable.
- "The test failed because the accessible name changed — that is a false positive." It is the test doing its job. Decide whether the name was supposed to change, then update it deliberately.
- "If the component test passes, the feature works." It works in a document with no layout, one component, mocked network and no navigation. Three of those four are where integration bugs live.
- "Waiting is flaky, so add a longer wait." Waiting for a duration is flaky. Waiting for a condition is not, and it is also faster on a fast machine.
- "Render count is a behaviour." It is a performance property, and if you care about it, measure it rather than asserting on it in a correctness test (What a Component Costs to Render).
Measuring it, and what changes in the field
- Suite wall-clock and per-test time. A component suite that has crept into minutes usually contains tests that should be pure-logic tests (Testing Pure Logic).
- Count of tests that failed on a refactor with no behavioural change — the direct measure of implementation coupling.
- Count of tests that query by test id versus by role or label. It is a proxy for how much accessibility signal the suite is giving away.
- Snapshot line count per test. Anything past a screenful is documentation nobody reads and a diff nobody inspects.
- In a simulated document, anything geometric is untestable: overflow, truncation, sticky positioning, whether a control is on screen. Those need a real browser or a visual test (Visual Regression Testing).
- On a loaded CI machine, framework updates take longer and any test that waits for a fixed duration becomes flaky. Condition-based waiting is unaffected.
- With a large list, mounting the real component with realistic data can be slow enough to distort the suite; that is a signal about the component as much as about the test (List Virtualization).
- In a server-rendered application, a component test does not exercise the server render or the hydration step, so mismatches survive it untouched (Hydration).
- Role-and-name queries are more work to write than test ids, and they fail when the accessible name changes — including when it changes correctly. That cost is the price of the signal.
- Real interaction sequences are slower than a synthetic
click, and the difference is multiplied by every test in the suite. - Keeping children real makes tests slower and failures wider: a bug in a shared child fails many parents. The alternative is mocking away the integration you wanted to test.
- Testing behaviour rather than structure means some regressions genuinely slip through — a spacing change, a colour change, a reordering — which is what the visual level is for.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALGiven / when / then against visible behaviour, and querying by role and accessible name, are framework-independent because both are statements about the DOM and the accessibility tree rather than about any component model.
- FRAMEWORK-SPECIFICThe mounting API and the update model differ: React batches and needs its own act-aware helpers, Vue and Svelte flush on a microtask so an await of the next tick is often enough, Angular has its own change-detection and fixture APIs, and Solid updates synchronously at the signal level, so the amount of waiting a test needs is not the same across them.
- SIMULATEDA simulated document such as jsdom or happy-dom implements the DOM API without a layout or paint engine: geometry queries return zeros, CSS is barely applied, and visibility is inferred from attributes. Running the same tests in a real browser changes which of them can pass at all.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Testing & Reliability Engineering — test doubles as a general subject: the difference between a stub, a spy, a fake and a mock, and why "mock at the boundary" is a rule with a long history rather than a preference.
- — Software Design — a component that is hard to test at this level is usually one that owns state it should have been given. That domain owns the argument; here it shows up as a painful mount.