6 min read
Testing
Tests that survive refactors: query by what users see, mock at the network boundary, and stop asserting on implementation details.
Principles
Test behaviour, not implementation
A test that asserts on state variables or internal method calls fails every time you refactor and passes when the feature is broken. Assert on what the user can observe.
Query the way a user finds things
Prefer role, label, and text over test IDs. It keeps tests readable and quietly enforces accessibility — an element you cannot query by role is usually one a screen reader cannot find either.
Mock at the network boundary, not the module
Mocking your own data layer means the test passes even when that layer is broken. Intercept HTTP instead, and the test exercises everything you actually wrote.
Cover the paths that hurt, not the lines that are easy
Coverage percentage is a poor target — it rewards testing getters. Aim at money paths, auth, offline behaviour, and every bug that reached production once.
Every bug fix ships with a failing-first test
Write the test, watch it fail, then fix. Without the failing step you have no evidence the test would ever catch a regression.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Behaviour over internals
Avoid
it("sets loading state", () => {
const { result } = renderHook(() => useCheckout());
act(() => result.current.submit());
// Asserting on a variable name. Rename it and this
// fails, while the user-visible behaviour is unchanged.
expect(result.current.isLoading).toBe(true);
});Coupled to internal naming. Breaks on refactor, silent when the UI is actually broken.
Prefer
it("shows a spinner while the order submits", async () => {
render(<CheckoutScreen />);
fireEvent.press(screen.getByRole("button", { name: /place order/i }));
// What the user actually experiences.
expect(await screen.findByRole("progressbar")).toBeVisible();
expect(screen.getByRole("button", { name: /place order/i })).toBeDisabled();
});Survives any refactor that preserves behaviour, and fails when the experience genuinely regresses.
Where to mock
Avoid
jest.mock("../api/orders", () => ({
postOrder: jest.fn().mockResolvedValue({ id: "1" }),
}));
// Your request building, serialization, and error mapping
// are all mocked away. The test proves almost nothing.The mock replaces the code under test, so the suite stays green while the real path is broken.
Prefer
const server = setupServer(
http.post("/api/orders", () => HttpResponse.json({ id: "1" })),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Error paths become a one-line override:
server.use(
http.post("/api/orders", () => new HttpResponse(null, { status: 500 })),
);Everything you wrote runs. Only the network is faked, and failure cases are trivial to express.
Async assertions
Avoid
fireEvent.press(submitButton);
await new Promise((r) => setTimeout(r, 1000)); // hope
expect(screen.getByText("Order placed")).toBeTruthy();A sleep is a flaky test with a timer. Too short and it fails in CI; too long and the suite crawls.
Prefer
fireEvent.press(submitButton);
// Retries until it appears or the timeout is genuinely exceeded.
expect(await screen.findByText("Order placed")).toBeVisible();Resolves as soon as the condition is met, and fails fast with a useful message when it isn't.
Review checklist
What I look for when reviewing a pull request that touches this area.
- No test asserts on internal state or private methods.
- Queries use role, label, or text before test IDs.
- HTTP is mocked at the boundary; your own modules are not.
- No arbitrary `setTimeout` waits — `findBy*` and `waitFor` only.
- Every production bug gained a test that failed before the fix.
- Critical paths — auth, payment, offline — are covered end to end.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/react-testing/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: react-testing
description: >-
Write React and React Native tests with Jest and Testing Library that
survive refactors: behaviour-based assertions, accessible queries,
network-boundary mocking, and proper async handling. Use when adding or
reviewing tests.
argument-hint: "[component or flow]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Testing
Apply when writing or reviewing tests. The guiding question is whether the test would still pass after a refactor that preserved behaviour — and still fail if the behaviour broke.
## Rules
1. **Test behaviour, not implementation** — A test that asserts on state variables or internal method calls fails every time you refactor and passes when the feature is broken. Assert on what the user can observe.
2. **Query the way a user finds things** — Prefer role, label, and text over test IDs. It keeps tests readable and quietly enforces accessibility — an element you cannot query by role is usually one a screen reader cannot find either.
3. **Mock at the network boundary, not the module** — Mocking your own data layer means the test passes even when that layer is broken. Intercept HTTP instead, and the test exercises everything you actually wrote.
4. **Cover the paths that hurt, not the lines that are easy** — Coverage percentage is a poor target — it rewards testing getters. Aim at money paths, auth, offline behaviour, and every bug that reached production once.
5. **Every bug fix ships with a failing-first test** — Write the test, watch it fail, then fix. Without the failing step you have no evidence the test would ever catch a regression.
## Patterns
### Behaviour over internals
**Avoid** — Coupled to internal naming. Breaks on refactor, silent when the UI is actually broken.
```tsx
it("sets loading state", () => {
const { result } = renderHook(() => useCheckout());
act(() => result.current.submit());
// Asserting on a variable name. Rename it and this
// fails, while the user-visible behaviour is unchanged.
expect(result.current.isLoading).toBe(true);
});
```
**Prefer** — Survives any refactor that preserves behaviour, and fails when the experience genuinely regresses.
```tsx
it("shows a spinner while the order submits", async () => {
render(<CheckoutScreen />);
fireEvent.press(screen.getByRole("button", { name: /place order/i }));
// What the user actually experiences.
expect(await screen.findByRole("progressbar")).toBeVisible();
expect(screen.getByRole("button", { name: /place order/i })).toBeDisabled();
});
```
### Where to mock
**Avoid** — The mock replaces the code under test, so the suite stays green while the real path is broken.
```ts
jest.mock("../api/orders", () => ({
postOrder: jest.fn().mockResolvedValue({ id: "1" }),
}));
// Your request building, serialization, and error mapping
// are all mocked away. The test proves almost nothing.
```
**Prefer** — Everything you wrote runs. Only the network is faked, and failure cases are trivial to express.
```ts
const server = setupServer(
http.post("/api/orders", () => HttpResponse.json({ id: "1" })),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
// Error paths become a one-line override:
server.use(
http.post("/api/orders", () => new HttpResponse(null, { status: 500 })),
);
```
### Async assertions
**Avoid** — A sleep is a flaky test with a timer. Too short and it fails in CI; too long and the suite crawls.
```tsx
fireEvent.press(submitButton);
await new Promise((r) => setTimeout(r, 1000)); // hope
expect(screen.getByText("Order placed")).toBeTruthy();
```
**Prefer** — Resolves as soon as the condition is met, and fails fast with a useful message when it isn't.
```tsx
fireEvent.press(submitButton);
// Retries until it appears or the timeout is genuinely exceeded.
expect(await screen.findByText("Order placed")).toBeVisible();
```
## Review checklist
- [ ] No test asserts on internal state or private methods.
- [ ] Queries use role, label, or text before test IDs.
- [ ] HTTP is mocked at the boundary; your own modules are not.
- [ ] No arbitrary `setTimeout` waits — `findBy*` and `waitFor` only.
- [ ] Every production bug gained a test that failed before the fix.
- [ ] Critical paths — auth, payment, offline — are covered end to end.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim