AI will happily generate component tests that assert implementation trivia and snapshot dumps — green forever, catching nothing. Constrain generation to user-visible behavior through accessible queries — getByRole, getByLabelText, userEvent — and the same generator produces tests that survive refactors and double as an accessibility smoke check.
Tier: Foundation · Workflow area: Testing & quality
The test suite you get from an unconstrained "write tests for this component" prompt is worse than none: it cements the current implementation and objects to every refactor. The fix is not writing tests by hand — it is prompting with the testing philosophy baked in.
@testing-library/user-event and @testing-library/jest-dom installed and configured.testid as the documented last resort.Prompt with the constraint set, not just the request. The template that changes the output class:
Write Vitest + React Testing Library tests for PaymentForm.tsx
(attached, with its props type). Hard rules:
- Queries: getByRole / getByLabelText / getByText only. findBy* for
async appearance. No getByTestId unless you first explain why no
accessible query can work. No container.querySelector. No snapshots.
- Interactions: userEvent only (setup() pattern) — no fireEvent.
- Assert what the USER can observe: visible text, accessible names,
enabled/disabled state, focus location, what onSubmit was called
with. Never assert state variables, class names, or child props.
- Async: await findBy* or waitFor; no arbitrary timeouts.
Cover: happy path, each validation error, server-error display,
double-submit prevention, and keyboard-only submission.
Read the generated queries as an accessibility report. This is the hidden payoff. If the model resorts to getByTestId('submit-btn'), ask why:
You used getByTestId for the submit button. What accessible query
would work, and what would the component need to change for it?
The honest answer is often "the button has no accessible name" or "the field's label is not programmatically associated" — a real accessibility bug the test just found. Fix the component (<label htmlFor>, aria-label, real <button>), then use the role query. Every test-id in a generated suite is a lead worth pulling.
Force the unhappy paths into existence. Generators gravitate to happy paths. A sample of what the constrained output should include:
it('shows a validation error and keeps focus for correction', async () => {
const user = userEvent.setup();
render(<PaymentForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/card number/i), '1234');
await user.click(screen.getByRole('button', { name: /pay now/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/card number is invalid/i);
expect(onSubmit).not.toHaveBeenCalled();
});
Note what it asserts: the error is in a role="alert" (announced to screen readers), the callback did not fire, and the queries would survive a complete restyle. If generated error tests assert a CSS class instead of an alert role, the component may be the problem again — errors that are only a red border are invisible to assistive tech.
Audit for decorative assertions before accepting. The three tells in generated suites: assertions that restate the setup (expect(screen.getByText('Pay now')).toBeInTheDocument() right after rendering a button labeled "Pay now" — true but content-free unless conditional rendering is the point), over-mocked internals (mocking a child component and asserting the mock rendered), and waitFor blocks with no assertion inside. Delete or strengthen each. A quick heuristic: for each test, ask "what plausible bug makes this fail?" No answer, no test.
Prove the suite bites. Manually sabotage the component — remove the disabled-while-submitting logic, break a validation branch — and confirm a test fails each time. This is the two-minute manual version of mutation testing; the full version (StrykerJS) is the next playbook and this suite's real exam.
Make the template contagious. Commit the best generated file as PaymentForm.test.tsx and extract the prompt into your instruction file (CLAUDE.md / Copilot instructions / Cursor rules) so every future "write tests" request inherits the constraints by default. The team's marginal cost of behavior-first testing drops to zero.
getByRole failures come with a printout of every role in the rendered tree — read it. It is simultaneously your debugging tool and a miniature accessibility tree audit of the component.
it() yourself as the pattern; put the hard rules in .github/copilot-instructions.md.getByRole support is limited, so getByLabelText (accessibilityLabel) and getByText lead; the behavior-first rules are unchanged.querySelector, zero snapshots, and every testid accompanied by a comment explaining why role/label queries could not work.The accessible-query constraint is doing double duty: it makes tests refactor-proof and turns your test generator into a continuous accessibility linter. When AI cannot find a component by role or label, neither can a screen reader — treat every such failure as a component bug first and a test problem second.