React Testing Library Intro - discussion (2022-10-08)
We’re trying to upgrade our unit/integration tests so they actually protect the route story instead of asserting random DOM trivia.
What do you consider a good “unit” test boundary in a React app (component, route shell, behavior slice)? Do you have conventions for making tests narratable (rendered markers, stable contract keys) without turning everything into data-testid soup? What patterns have reduced flaky tests the most for you?
Comments (10)
Back to latestThe best tests read like user behavior. The worst tests read like “this div exists because I stared at the DOM once”.
If you want tests that survive refactors, you need a stable vocabulary in the UI.
We render small contract markers (lane/posture/version) and assert on those instead of brittle structure.
Related: Testing and Debugging and Performance and Rendering Best Practices.
A pattern that helped us: assert the contract marker, then assert one user-visible outcome.
tsx
render(<CheckoutRoute />);
expect(screen.getByTestId('court')).toHaveAttribute('data-court', 'dual');
await user.type(screen.getByRole('textbox', { name: /email/i }), 'a@b.com');
expect(screen.getByText(/valid/i)).toBeInTheDocument();Counterpoint: “no testids ever” is a religion that makes tests worse.
If you have an internal contract marker you actually *want* to assert on, a data-* attribute is fine.
Agreed. Our compromise: roles/labels for user intent, and a small number of explicit markers for route posture.
What we avoid is data-testid on every nested span just to satisfy a brittle test.
Yep. A few honest markers beat a thousand accidental ones.
Flake killer: stop waiting for arbitrary timeouts and start waiting for a visible state transition. If the route has a posture marker, you can wait for data-status='ready' instead of sleeping.
tsx
render(<SearchRoute />);
await waitFor(() => expect(screen.getByTestId('route')).toHaveAttribute('data-status', 'ready'));
expect(screen.getByRole('heading', { name: /results/i })).toBeInTheDocument();We also stopped mocking everything. Mocking made tests “fast” but it detached them from the actual route story.
A couple bigger tests with real wiring were worth more than 200 tiny mocks.
Our best unit boundary is usually “component + its contract props”.
If the component relies on a global, we treat that as a smell and either inject it or test at the route boundary instead.
A lot of flake came from effects that settle state after render.
Once we deleted those and stored derived evidence up front, tests stopped racing hidden timing.
When a test fails, I want a screenshot to be enough. If nothing on screen can prove posture/state, debugging is pure guesswork.
One rule: tests should assert on behavior *and* the contract marker that explains the behavior.
Otherwise you’ll keep writing tests that pass for the wrong reasons.