React / Coordinated application behavior / Lesson 12
Testing and accessible interactions
Verify observable component contracts, coordinate updates correctly, and test the full keyboard and error-recovery journey.
On this page
Lesson goals
- Assert behavior that users rely on
- Test a small interactive contract
- Cover accessibility as behavior
Assert behavior that users rely on
A component test should connect an input or interaction to an observable result under a justified expectation. Prefer assertions about names, values, messages, and state over private Hook call counts or incidental wrapper structure. Derive expected results from the requirement, not by copying the implementation’s formula into the test. A snapshot can document structure but does not prove that clicking, typing, focus, or recovery works. Begin with the smallest meaningful contract and add boundary cases such as empty input, rejected action, and repeated interaction. Keep network and time dependencies controlled so a failure points to the behavior rather than an unrelated service.
Coordinate committed work before assertions
React updates can be scheduled, so tests need the renderer’s supported coordination before inspecting the result. The official act helper groups test interactions and flushes relevant React work before assertions when used as documented. Modern testing libraries often wrap their interaction and render helpers, while a real browser test can wait for an observable condition. Do not scatter arbitrary sleeps until a flaky test turns green. Fake timers and mocked promises still require appropriate integration with the chosen runner. The test environment may need its documented act configuration; that is test setup, not a production global to add without understanding it.
Test a small interactive contract
The first example exposes a button whose accessible name communicates a fixed action and a visible output showing the count. A useful test locates “Add one,” activates it, and asserts that the output becomes one. It should also verify repeated clicks and any documented lower or upper bound if one exists. This example intentionally has no bound rather than inventing a requirement. Native button semantics provide keyboard activation, but the surrounding product still needs meaningful focus styling and order. An accessible-name query can catch a lost label that a CSS-selector-only test would miss.
import { useState } from "react";
export default function App() { const [count, setCount] = useState(0); return <main><h1>Test target</h1><button type="button" onClick={() => setCount(c => c + 1)}>Add one</button><output aria-label="Count">{count}</output></main>; }
Initial Count: 0. After Add one: 1.
A test can locate the named button and assert the committed Count output after activation.
Make a minimal coordinated verification explicit
The second module exports a verify helper to demonstrate act without assuming an installed third-party test runner. It mounts an isolated counter, clicks inside act, reads the committed output, and always unmounts. The host must supply a separate empty container and the documented test environment configuration. This is a teaching helper, not a complete project test suite or a recommendation to replace user-level interaction tools with raw events everywhere. Real keyboard and typing behavior needs browser or appropriately modeled interaction tests. Never mount a second test root into a container already owned by the application.
import { act, useState } from "react";
import { createRoot } from "react-dom/client";
function Counter() { const [n, setN] = useState(0); return <><button type="button" onClick={() => setN(x => x + 1)}>Add</button><output>{n}</output></>; }
export async function verify(container) { const root = createRoot(container); try { await act(async () => root.render(<Counter />)); await act(async () => container.querySelector("button").click()); const result = container.querySelector("output").textContent; if (result !== "1") throw new Error("Expected one"); return result; } finally { await act(async () => root.unmount()); } }
export default function App() { return <main><h1>Coordinated test target</h1><Counter /></main>; }
Calling verify with an isolated empty test container returns "1".
The helper needs a separate test container and act-enabled environment. It returns one only after the assertion and releases its root in finally.
Cover accessibility as behavior
Check labels, descriptions, keyboard activation, focus entry and exit, visible focus, and feedback after invalid input. A disclosure must expose its current state and reveal the correct content; a dialog requires a full focus and dismissal contract. Automated accessibility checks find some errors but cannot establish that the workflow is understandable or that every custom widget follows its pattern. Include supported assistive technology and browser combinations in the product’s evidence. React can render semantic HTML, but it does not make a custom interaction accessible automatically. Keep an explicit manual or browser-based review for the parts that a unit test cannot meaningfully prove.
Common errors and fixes
A test reads the old DOM immediately
- Why it happens
- It asserted before scheduled work committed.
- How to fix it
- Use supported coordination or await the observable result.
- Prevent it next time
- Avoid arbitrary sleep-based assertions.
A snapshot passes while keyboard use fails
- Why it happens
- Only initial markup was checked.
- How to fix it
- Exercise activation, focus, and recovery paths.
- Prevent it next time
- Match tests to the interaction contract.
Tests leak state into each other
- Why it happens
- Roots or external subscriptions were not released.
- How to fix it
- Unmount and clean fixtures on every completion path.
- Prevent it next time
- Run focused tests independently and together.
Check your understanding
1. Choose assertions for a submit workflow that can fail validation.
Show worked solution
Assert a useful labeled input, an invalid result with specific correction text, preserved safe input, successful correction, and a truthful completion state. Also inspect keyboard focus and message association. A passing submit callback count alone does not establish any of those user outcomes.
2. Why should test cleanup run after a failed assertion too?
Show worked solution
A failed assertion does not release mounted roots, timers, or listeners automatically. Use the runner’s cleanup hooks or finally boundaries so later tests receive independent fixtures. Otherwise one legitimate failure can trigger a cascade of misleading failures caused by leaked resources rather than new defects.
Key takeaways
- Test observable promises and realistic interactions.
- Coordinate React work before assertions.
- Accessibility needs more than static markup checks.