React Testing Library Intro - discussion (2023-01-03)
The React Testing Library intro is short, but it sets a clear posture: test the app the way a user experiences it, and prefer queries that reflect accessibility and intent.
What conventions do you use to keep tests stable without coupling to implementation details? Do you treat test selectors as part of the UI contract (and document them), or keep them implicit? How do you balance fast unit tests with route-level integration tests when behavior depends on navigation and data loading?
Comments (10)
Back to latestThe best rule: if you're querying by class names, you're probably testing the wrong thing.
We keep tests stable by making the UI contract visible (names, roles, labels) instead of adding a pile of test-only IDs.
If the UI isn't queryable by intent, that's a product/accessibility issue too.
Related: Testing and Debugging.
We do have a small set of explicit data attributes, but we version them like a contract surface.
If a selector changes, it should be treated like a breaking change for the test suite.
On route-level tests: once your app has real navigation, the boundary isn't a component anymore, it's a route shell.
Integration tests that exercise a flow can actually be *more* stable than brittle unit tests.
We basically follow the intro's rule of thumb: reach for roles/labels first, and only use test IDs when the UI contract genuinely has no semantic handle.
ts
render(<Settings />);
await user.click(screen.getByRole('button', { name: /save/i }));
expect(screen.getByRole('status')).toHaveTextContent(/saved/i);One thing we did to keep route-level tests stable is render a tiny contract marker in the shell (internal builds only). Then tests can assert on it without coupling to layout:
tsx
return <section data-route="/inbox" data-posture="cache-bypass" />;Counterpoint: a small set of data-testid selectors can be a real contract if you treat them like an API surface.
The problem is when they become a dumping ground for everything that is hard to query.
Yeah, that's where I land too. I don't hate test IDs.
I hate when tests depend on them because the UI isn't queryable by intent (which is often an a11y smell).
Exactly. If the app has good names/roles, the test suite gets simpler without trying.
A misunderstanding I see: people think RTL means "never assert anything internal".
For us, asserting on rendered evidence markers (route keys, cache lane) is still testing the UI contract, not implementation details.
If you want tests that survive refactors, do the same thing the forum docs push: make behavior visible in render.
Then your tests are just reading the contract you already needed for support/debugging.
Also: avoid over-mocking once you have navigation.
A route shell test that hits a real loader + renders a stable contract marker is usually less brittle than a unit test that mocks three layers and asserts on call order.