Rules for testing React components with React Testing Library, Vitest and MSW, and for deciding what to test at which layer: rendering, queries, user-event, jest-dom, waitFor, act warnings, mocking the network, flat test files, custom hooks, coverage. Use when writing, fixing or reviewing a *.test.tsx component test or planning a test strategy. Background knowledge that agents preload; not a command.
Installs into .claude/skills of the current project.
Sponsors
SSkills DirectorySkills Directory
Know which skills are safe — weekly.
SSkills DirectorySkills Directory
Is that skill safe to install?
Are you the author of Component Test Rules?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/janszewczyk-component-test-rules)
---
name: component-test-rules
description: "Rules for testing React components with React Testing Library, Vitest and MSW, and for deciding what to test at which layer: rendering, queries, user-event, jest-dom, waitFor, act warnings, mocking the network, flat test files, custom hooks, coverage. Use when writing, fixing or reviewing a *.test.tsx component test or planning a test strategy. Background knowledge that agents preload; not a command."
user-invocable: false
---
<!-- generated by knowledge.mjs from 37 note(s) (sha256 24128d06490f); edit the notes in knowledge/, then run build -->
# Component test rules (React Testing Library)
Apply these rules to every component test you write or review, and to every test strategy you plan. Examples from shared notes may query through Storybook's `canvas`; in React Testing Library use `screen`. For story files, use the Storybook rules instead.
## Always apply (critical, high)
### Make every test able to fail when the behaviour it names breaks
_critical · any_
**Rule.** End every test with an assertion that would fail if the behaviour in its name broke. Before you
trust a new test, check that it can fail: break the condition it checks, or assert the opposite,
and see it go red.
**Why.** A test that only renders and clicks, or asserts something that is always true, stays green while the feature is broken, so the suite reports coverage that does not exist.
### Fix the cause of a flaky test instead of rerunning it until it passes
_critical · any_
**Rule.** Treat a test that fails without a code change as a defect: find its nondeterminism (clock, random data, scheduling, network, test order, shared state) and remove it.
A rerun (`--reruns`, `@flaky`, `retryTimes`, `retry`) is never the fix: a suite that reruns reports each test that passed only on a rerun as flaky, to be fixed.
**Why.** A rerun turns the suite green while the race or leak behind the failure is still in the code, and once red can mean "just flaky", people stop reading failures and miss the real ones.
### Wait for a condition the user would see, never for a fixed time
_critical · any_
**Rule.** When the result of an action arrives later, wait for the condition itself: a query that retries
until the element appears, or a retrying assertion on the value or call you expect. Give a long
operation a longer timeout on that wait instead of a sleep before it.
**Why.** A fixed sleep is too short on a slow CI machine and too long everywhere else, so the suite is both flaky and slow, and its failures name the assertion after the sleep instead of what never happened.
### Fail the test on any request no handler matches
_high · typescript, msw@>=3_
**Rule.** Start the test server with the `error` strategy for unhandled traffic, so a request no handler matches
fails the test.
**Why.** The default strategy prints a warning and lets the request pass through, so a call with a wrong URL hits the real network, and the test can stay green with only a log line to show for it.
**Correct:**
```ts
beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
```
### Fake HTTP in component tests with MSW, not by mocking fetch or the API client
_high · typescript, msw_
**Rule.** In component and integration tests, fake HTTP with MSW handlers that act like the server, shared between
tests and browser development. Keep `fetch` and your own API client real.
**Why.** A mocked `fetch` or API client passes while the real call has a wrong URL, method, header or auth, and each test ends up reimplementing a piece of the backend.
**Correct:**
```tsx
// handlers.ts, used by the test server and the dev worker
export const handlers = [http.get("/api/invoices", () => HttpResponse.json(invoices))];
render(<InvoiceList />);
expect(await screen.findAllByRole("listitem")).toHaveLength(invoices.length);
```
### Keep the unit under test real, and change the test rather than the code
_high · any_
**Rule.** Mock only what the unit under test depends on, never the unit itself. When a new test fails, fix
the test; if the failure shows a real defect in the code, report it instead of editing the code or
weakening the assertion to make the test pass.
**Why.** A mocked or quietly patched unit makes the test check itself: it passes while the shipped code is still broken, and the bug it found disappears from view.
### Find elements the way a user or a screen reader does
_high · any_
**Rule.** Locate elements by their role and accessible name first, then by their label, then by visible
text. Reach for a test id only when the element has no accessible identity at all, and fix the
component when that is because its semantics are wrong.
**Why.** A test that finds a button by its role and name keeps passing through any refactor that keeps the button usable, and fails when a user could no longer find it.
**Exceptions.** A test id is fine for an element with correct semantics that still has nothing stable to query by, such
as dynamic text and no role. It beats a CSS class, because it makes the link between test and code explicit.
### Make every test pass alone and in any order
_high · any_
**Rule.** Write each test so it passes when run alone and in any order. Configure and reset mocks before each
test, and share repeated setup through a small helper function, never through a mutable variable
that tests write to.
**Why.** A test that leans on an earlier one passes in the full run and fails when filtered or reordered, which shows up as a flaky suite whose failures point at the wrong test.
### Mock only what a test cannot run or could only run with great difficulty
_high · any_
**Rule.** Mock something only when the test could not be written without the mock or would be very awkward with
the real thing. In UI tests that means the network, animations, and services with a real cost or effect
such as payments and e-mail. In E2E tests it means almost nothing.
**Why.** Every mock cuts the link between the tested code and what it replaced, so the suite stays green when that link breaks in production.
### Reproduce a bug with a failing test before fixing it
_high · any_
**Rule.** When a bug earns a test, write the test that reproduces it before touching the code, and see it fail.
Decide whether it earns one the way you prioritise any other work, by what the breakage costs users.
**Why.** A regression test written after the fix may never have failed, so it can pass without covering the bug, and the bug comes back with the test still green.
**Exceptions.** A bug that costs users little, in a place that is expensive to test, can get a fix with no test.
### Spend most testing effort on integration tests
_high · any_
**Rule.** Give integration tests the largest share of testing effort, keep E2E tests to the happy paths of
critical flows, use unit tests for pure functions and complex logic, and leave typos and wrong types
to static analysis. Choose the layer by the problem: a calculator edge case is a unit test, a form
submit with validation is an integration test, logging in through the whole app is an E2E test.
**Why.** A suite of isolated unit tests stays green while the pieces fail to work together, and a suite of E2E tests is too slow and costly to reach the edge cases.
**Exceptions.** This holds for one codebase, a frontend or a monolith. It says nothing about a microservice architecture.
### Test only what the end user or the developer using the code can touch
_high · any_
**Rule.** Write tests against what the code's two users have: the rendered output an end user sees and clicks,
and the props and providers a developer passes in. When a refactor that keeps the behaviour forces a
test to change, rewrite that test so it stops depending on the implementation.
**Why.** A test that knows state, handler names or component structure fails on a harmless refactor and passes when the feature is broken, and the code ends up serving a third user that nobody needs.
### Pick getBy, queryBy or findBy by what the assertion expects
_high · typescript, testing-library_
**Rule.** Use `getBy*` for an element that must already be on screen, `queryBy*` when the assertion is that
it is absent, and `findBy*` (or `waitFor`) for anything that appears after an interaction, a
request or an animation.
**Why.** `getBy*` throws before a `not.toBeInTheDocument()` assertion can run, so a negative check written with it fails for the wrong reason.
**Correct:**
```ts
await expect(canvas.queryByRole("alert")).not.toBeInTheDocument();
await userEvent.click(canvas.getByRole("button", { name: /save/i }));
await expect(await canvas.findByText(/saved/i)).toBeVisible();
```
### Assert with jest-dom matchers, not by reading DOM properties
_high · typescript, react, testing-library_
**Rule.** Assert with `@testing-library/jest-dom` matchers instead of reading DOM properties. Write an existence
check out as `expect(screen.getByRole(...)).toBeInTheDocument()`.
**Why.** A failing `expect(button.disabled).toBe(true)` reports only "expected true, received false", with no element and no hint of intent, so the failure takes longer to read than the fix.
**Correct:**
```tsx
expect(screen.getByRole("button", { name: /save/i })).toBeDisabled();
```
### Render through a custom render that wraps the app's providers
_high · typescript, react, testing-library_
**Rule.** Export one `render` from a `test-utils` module that wraps the component in every provider the app has,
such as router, theme, query client and auth. Tests import it from there, not from
`@testing-library/react`.
**Why.** Providers wrapped by hand in each test drift from the real app, so a component passes against a tree it never runs in, and adding a provider means editing every test.
**Correct:**
```tsx
import { render, screen } from "~/test-utils";
render(<InvoiceList />);
```
### Await findBy* instead of wrapping getBy* in waitFor
_high · typescript, react, testing-library_
**Rule.** Wait for an element to appear with `await screen.findBy*`, not with `waitFor` around `getBy*`.
**Why.** `waitFor` around `getBy*` is a longer way to write the same wait, and its callback invites extra assertions and side effects that make the test flaky.
**Correct:**
```tsx
const alert = await screen.findByRole("alert");
```
### Keep waitFor to one concrete assertion, with every action before it
_high · typescript, react, testing-library_
**Rule.** Give `waitFor` one concrete assertion, run clicks and other actions before it, and take any snapshot after
it.
**Why.** The callback reruns until it passes, so a click inside it can fire many times and make the test flaky, while an empty callback waits for nothing in particular and hides what the test depends on.
**Correct:**
```tsx
await user.click(screen.getByRole("button", { name: /save/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(screen.getByRole("status")).toHaveTextContent(/saved/i);
```
### Render the full component tree, with no shallow rendering or mocked children
_high · typescript, react, testing-library_
**Rule.** Render the component with `render()` and let its whole subtree mount. Keep child components and React's
own hooks real.
**Why.** With a child stubbed out, a change that breaks it breaks the app but not the test, and the few milliseconds saved are a poor trade for that confidence.
**Correct:**
```tsx
test("lists invoices", () => {
render(<InvoiceList invoices={invoices} />);
expect(screen.getAllByRole("listitem")).toHaveLength(invoices.length);
});
```
**Exceptions.** A child that does something a test cannot run may get an explicit mock, under the same bar as any other
mock.
### Reset mocks between component tests in the Vitest config
_high · typescript, vitest_
**Rule.** Set both `mockReset: true` and `restoreMocks: true` in the Vitest config of a component test suite, so call
history, implementations and spies never carry over to the next test.
**Why.** A mock left over from an earlier test makes a later one pass or fail depending on run order.
**Correct:**
```ts
export default defineConfig({
test: { mockReset: true, restoreMocks: true },
});
```
## Read when the task touches it
| Impact | Rule | Applies to |
| --- | --- | --- |
| medium | [Keep the happy path in global handlers, and put edge cases in the test that needs them](references/rules.md#keep-the-happy-path-in-global-handlers-and-put-edge-cases-in-the-test-that-needs-them) — Errors and edge cases go in the test through server.use(); resetHandlers after each test removes them. | typescript, msw |
| medium | [Assert what the user sees or can do, never internal state or markup](references/rules.md#assert-what-the-user-sees-or-can-do-never-internal-state-or-markup) — Check visibility, text, values, focus, ARIA state and callback calls; never state, class names or markup snapshots. | any |
| medium | [Keep test setup inline until repetition hides what differs between tests](references/rules.md#keep-test-setup-inline-until-repetition-hides-what-differs-between-tests) — A helper takes overrides and returns what the test needs, with no branch per case; a test shows what matters to it. | any |
| medium | [Derive the expected value from the test's own input, not a repeated literal](references/rules.md#derive-the-expected-value-from-the-tests-own-input-not-a-repeated-literal) — Assert against the data the test passed in (items.length, the prefilled value), never a hard-coded copy of it. | any |
| medium | [Keep snapshots small and inline, for output whose exact text matters](references/rules.md#keep-snapshots-small-and-inline-for-output-whose-exact-text-matters) — Snapshot error messages, logs or transformed code inline; replace a large snapshot with explicit assertions. | any |
| medium | [Query by the text users read, not by translation keys](references/rules.md#query-by-the-text-users-read-not-by-translation-keys) — Look up the default locale's real text, so a changed label is a change the test reports. | any |
| medium | [Use semantic HTML instead of adding ARIA so that a query works](references/rules.md#use-semantic-html-instead-of-adding-aria-so-that-a-query-works) — The right element and input type give the implicit role; add role or aria-* only for what HTML cannot express. | any |
| medium | [Choose the next test by what would hurt most if it broke](references/rules.md#choose-the-next-test-by-what-would-hurt-most-if-it-broke) — Start from the worst breakage in untested code, narrow to its units and users, write the manual steps, automate them. | any |
| medium | [Judge a suite by the use cases it covers, not by a coverage percentage](references/rules.md#judge-a-suite-by-the-use-cases-it-covers-not-by-a-coverage-percentage) — Use the coverage report to find untested code; set no percentage target for an application, and none for E2E tests. | any |
| medium | [Answer an act warning by waiting for the effect, not by wrapping calls in act](references/rules.md#answer-an-act-warning-by-waiting-for-the-effect-not-by-wrapping-calls-in-act) — render and user-event already use act; the warning usually means work finished after the test, so wait for it. | typescript, react, testing-library |
| medium | [Lint tests with the Testing Library and jest-dom ESLint plugins](references/rules.md#lint-tests-with-the-testing-library-and-jest-dom-eslint-plugins) — The plugins catch unawaited queries, wrong query variants and weak matchers before review does. | typescript, react, testing-library |
| medium | [Leave cleanup to the test setup, not to each test file](references/rules.md#leave-cleanup-to-the-test-setup-not-to-each-test-file) — A global afterEach makes cleanup automatic; without Vitest globals, register it once in the setup file. | typescript, react, testing-library, vitest |
| medium | [Write one test per user flow, with all its steps and assertions](references/rules.md#write-one-test-per-user-flow-with-all-its-steps-and-assertions) — One Arrange, then as many Act and Assert as the flow needs; one assertion per test is an outdated rule. | typescript, react, testing-library |
| medium | [Drive interactions with user-event, not fireEvent](references/rules.md#drive-interactions-with-user-event-not-fireevent) — user-event plays the whole event sequence a user causes; fireEvent dispatches one event and skips the rest. | typescript, react, testing-library |
| medium | [Query through screen, not through what render returns](references/rules.md#query-through-screen-not-through-what-render-returns) — Use screen for every query and take from render only what screen lacks, such as rerender; never name the result wrapper. | typescript, react, testing-library |
| medium | [Share component test setup through a helper, not nested describe and beforeEach](references/rules.md#share-component-test-setup-through-a-helper-not-nested-describe-and-beforeeach) — Keep the file flat with setup inline or from setup(); use before and after hooks only for cleanup and global resources. | typescript, react, testing-library |
| medium | [Test a custom hook through a component that uses it before reaching for renderHook](references/rules.md#test-a-custom-hook-through-a-component-that-uses-it-before-reaching-for-renderhook) — A one-component hook goes through that component, a reusable hook through an example; renderHook is the fallback. | typescript, react, testing-library |
| low | [Write the test first when the requirements are clear](references/rules.md#write-the-test-first-when-the-requirements-are-clear) — TDD for pure functions, bug fixes and UIs with a settled design; in exploratory coding, test once the direction holds. | any |