Verified against Claude Code · 2026-07-25
Write component tests that check behavior, not implementation details
Generates React Testing Library tests driven by accessible queries and user-visible behavior, with a guard against testing internal state, CSS classes, or props directly.
The prompt
Ready to copy — highlighted parts are example details you can swap.
You are writing tests for a React component using React Testing Library and Vitest. You test what a user can see and do, never internal state, prop values, or implementation details a user has no way to observe. COMPONENT A SearchableList component with a text input that filters a list of items client-side, and a "Clear" button. KEY INTERACTIONS TO COVER Typing in the search box filters the visible list to matching items; clicking Clear resets the input and shows the full list; typing something with no matches shows a "No results" message. MOCKING BOUNDARY Items are passed in as a prop in these tests — no network mocking needed for this component. TESTING RULES Query elements the way a real user would find them: getByRole, getByLabelText, getByText, in that rough order of preference. Only fall back to a test-id when there is genuinely no accessible way to find the element, and say so explicitly in a comment at that exact line when you do, since a test-id fallback is often a signal of a real accessibility gap in the component, not just a testing inconvenience. Never query or assert on component internal state, prop values, or CSS class names directly — if a behavior is worth testing, it is worth testing through what actually renders on screen or what happens when a user interacts with it. Use userEvent, not fireEvent, for every interaction, and always await it — userEvent's interaction methods are asynchronous by design to accurately simulate real browser event timing, and a missing await is the single most common cause of an intermittent, hard-to-reproduce flaky test in this exact stack. Write one test per meaningful behavior listed in the key interactions, named as a plain sentence describing the behavior — "shows an error when the field is left empty" — never as "test 1" or "renders correctly." Where the component does something conditionally, write both the positive case and the negative case explicitly; never test only the happy path and assume the conditional branch is covered by implication. Mock only at the exact boundary named above — a network call, a store — never by reaching into the component's internals to fake a piece of its own state. Avoid an arbitrary waitFor with a fixed timeout as a substitute for waiting on the actual condition that matters — prefer an assertion inside waitFor, or a query variant like findByRole, that resolves the moment the real DOM state changes, rather than a bare delay that either flakes on a slow CI machine or wastes time waiting past when the assertion could already have passed. When a test needs to wait for an element to disappear, use waitForElementToBeRemoved rather than polling queryBy* in a hand-written loop. OUTPUT FORMAT A complete test file, imports included, written for Vitest. Group related tests with describe blocks named after the feature being tested, not after the component's file name. If any test-id fallback was needed, list it separately at the end as a flagged accessibility gap worth fixing in the component itself.
Customize
Optional — swap in your own details for the highlighted parts above.
Why this works
The query-by-role-first rule operationalizes Testing Library's own guiding principle — tests should resemble how a real user interacts with the app — and it doubles as an incidental accessibility check: if an element genuinely cannot be found by getByRole or getByLabelText, that is frequently a real a11y gap the component itself should fix, not just a testing inconvenience to route around silently with a test-id, which is exactly why the fallback has to be flagged rather than used quietly. The mandatory await on every userEvent call targets a specific, well-documented bug class: Testing Library's userEvent methods return promises to accurately simulate real browser event timing rather than firing events synchronously, and a missing await is Testing Library's own most commonly cited cause of intermittent, hard-to-reproduce test failures that pass locally and fail in CI under different timing. Requiring both the positive and negative case for every conditional behavior closes the specific gap where an LLM writes a technically-passing test suite that only ever exercises the happy path and never actually proves the "No results" message appears when it should — a suite that looks complete by line count while leaving the exact branch most likely to regress silently untested. The preference for findByRole and condition-based waitFor over a fixed-duration wait targets a second, distinct source of test flakiness beyond the missing-await problem: a hardcoded delay is either too short, and fails intermittently on a loaded CI runner, or too long, and silently slows the whole suite down while adding no confidence, whereas a condition-based wait resolves the instant the actual DOM state it checks for becomes true, which is both faster on a fast machine and more reliable on a slow one. Naming waitForElementToBeRemoved specifically, rather than leaving removal-testing to whichever polling pattern the model reaches for, matters because a naive queryBy*-in-a-loop implementation is exactly the kind of test-of-the-test code Testing Library already solved with a dedicated utility — reinventing it inline adds surface area for a subtly wrong loop condition to hide in, for no benefit over calling the function that already exists for precisely this case.
What you get back
describe('filtering the list', () => { it('shows only matching items when typing in the search box', async () => { const user = userEvent.setup(); render(<SearchableList items={['Apple', 'Banana', 'Cherry']} />); await user.type(screen.getByRole('textbox', { name: /search/i }), 'ban'); expect(screen.getByText('Banana')).toBeInTheDocument(); expect(screen.queryByText('Apple')).not.toBeInTheDocument(); }); it('shows a "No results" message when nothing matches', async () => { const user = userEvent.setup(); render(<SearchableList items={['Apple', 'Banana']} />); await user.type(screen.getByRole('textbox', { name: /search/i }), 'zzz'); expect(screen.getByText(/no results/i)).toBeInTheDocument(); }); }); No test-id fallback needed — every element in this component was reachable via getByRole or getByLabelText.
Verified against
Claude Code Sonnet 4.6 · 2026-07-25
Cursor Cursor 2.1 · 2026-07-26
Changelog
- 2026-07-25 — Initial publish, verified against Claude Code (Sonnet 4.6) and Cursor 2.1 using React Testing Library 16 and Vitest 3.
Need this built into your business?
If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.
EXPLORE CUSTOM SOFTWARE
