A UI component test checks that a Save button is in its primary style by asserting `expect(container.querySelector('.btn-primary')).toBeTruthy()`. Why do reviewers call that assertion brittle, and what would you assert instead?
answer
- styling hook, not user-facing behavior
- renaming the class breaks nothing real
- would a user ever notice?
- query by role and visible name
- appearance belongs to visual regression
basics
~20 sA CSS class is a styling hook, not behavior: renaming or reorganising it breaks the test even though the button still looks and works the same to a user. Assert what a user perceives — the visible label, the role, the enabled state.
solid answer
~50 sThe class name is an internal styling detail. Nothing about `.btn-primary` is part of what the user experiences — they see a button labelled "Save" that they can click. So the moment someone renames the class, switches to CSS modules, or moves to a utility-class framework, the test goes red while the product is completely unchanged. That is the definition of a brittle test: it fails for a reason the user would never notice. Instead I'd query the button the way a user finds it — by its role and visible name — and assert the thing that actually matters at that moment: that it's on the page, that it's enabled or disabled, that the label reads "Save". If the *visual* primary treatment is genuinely what needs guarding, that's a job for a visual-regression snapshot, not for a class-name string in a unit test.
code
javascript · 13 linesimport { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import SaveButton from './SaveButton';
test('the save button is available to the user', () => {
const { container } = render(<SaveButton />);
// brittle: passes on a styling hook, blind to real breakage
expect(container.querySelector('.btn-primary')).toBeTruthy();
// durable: the control as a user finds and experiences it
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
});go deeper
Be able to say plainly that a CSS class is a styling detail the user never sees, and that you would instead check the button's visible text, its role, and whether it is enabled.
Explain the failure mode in both directions: the assertion goes red on a pure rename or a move to CSS Modules, and it stays green when the button loses its label or its interactivity. Name the replacement assertion concretely.
Show where appearance is legitimately verified — visual regression owns pixels — and describe the cost a suite full of class-name assertions imposes on a design-system migration in a real codebase.
Own the standard: decide what a component test is allowed to reach for, which layer verifies appearance, and how you stop styling churn from generating test churn across many teams.
## What the assertion is really doing `container.querySelector('.btn-primary')` reaches into the rendered DOM and asks: "is there an element carrying this exact class string?" `toBeTruthy()` then passes if anything was found. Notice what the test never checks: that a button exists, that it says "Save", that a user could click it, or that it looks any particular way. It checks that one specific string appears in one specific attribute. ## Why that makes the test brittle A test is brittle when it fails for reasons the user would not notice, and passes in situations the user *would* notice. This assertion does both. It **fails on non-changes**. Rename `btn-primary` to `button--primary`, adopt CSS Modules so the class is emitted as `Button_btnPrimary__x7f2q`, move to utility classes so the styling lives in `bg-brand text-white`, or extract the button into a design-system package — every one of those leaves the product pixel-identical and every one of them turns the test red. Multiply that by a suite of a few hundred tests and a one-line styling change turns into an afternoon of test edits. It **passes on real breakage**. Suppose someone deletes the button's text, or the button renders `disabled` when it shouldn't, or the element is a `<div>` that no keyboard user can reach. The class is still there, so the test is still green. The assertion never touched the part a user cares about. ## What to assert instead The replacement rule is simple: **assert the things a user can perceive or act on.** For a button that is: - Its **visible text** — "Save", not "Sav" and not an empty icon-only button. - Its **role** — it is a button, so keyboard and assistive-technology users can operate it. - Its **state** — enabled or disabled, pressed or not, which is what changes as the user interacts. Querying by role and accessible name is how the Testing Library family expresses this, and it is deliberate: the query itself mirrors how a real user locates the control. ```javascript // brittle: a styling hook the user never perceives expect(container.querySelector('.btn-primary')).toBeTruthy(); // durable: the control as a user finds and experiences it expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled(); ``` The second version survives every styling refactor listed above, and it fails loudly if the button loses its label, its role, or its interactivity. ## "But I really do need to test the styling" Sometimes the visual treatment genuinely is the requirement — a destructive action must read as destructive. Two honest answers exist, and neither is a class-name string assertion. The first is to test the *behavioural* proxy rather than the paint. If "primary" means "this is the default action that Enter triggers", assert that. If "danger" means "this control asks for confirmation", assert the confirmation appears. The second is to let a **visual-regression** check own appearance. A pixel diff is the tool that actually knows whether the button turned the right colour; a class-name assertion only knows whether a string is present, which is a proxy that can be right while the rendering is wrong (the stylesheet did not ship, a later rule overrode it, the class was applied to a wrapper instead of the control). ## The generalisation Class names are one instance of a wider family of implementation-coupled assertions: DOM nesting depth, wrapper elements, internal variable names, generated ids. They share one property — a refactor can change them freely without changing anything a user experiences. Any assertion with that property is a future false failure waiting to happen. The check to run before writing an assertion: *if this stopped being true, would a user notice?* For `.btn-primary`, the answer is no. For "there is an enabled button labelled Save", the answer is yes. Write the second one.
- If the class name is off limits, how would you test that a delete button is styled as a destructive action?I'd separate the behavioural requirement from the paint. Behaviourally, "destructive" usually means something testable: the action asks for confirmation, or it is not the default Enter action. I'd assert that in the component test. For the actual colour and treatment, a visual-regression check is the tool that can genuinely see it — a class-name assertion only proves a string is present, which can be true while the rendering is wrong.
- Is asserting on a data-testid attribute any better than asserting on a class name?It is better in one narrow way — a testid exists for tests, so nobody renames it during a styling refactor, which removes the false-failure problem. But it is still not user-perceivable: a test that only finds elements by testid will happily pass on a button with no label, no role and no reachable keyboard path. Use it as a last resort for elements with no accessible handle, not as the default.
saying these in an interview costs you the question
- "The class is in the markup, so the test is checking the UI"
- Treating a class-name assertion as proof the button is styled correctly
- "Just update the tests whenever we rename classes"
- Reaching into container.querySelector as the default way to find elements
- Believing the test would catch a button that lost its label