# Testing Web

> Use when writing or fixing frontend unit, component or custom-hook tests with Vitest or Jest plus Testing Library — rendering a component in jsdom, testing a hook in isolation, choosing between sync and async queries, silencing act warnings, mocking fetch, or migrating a Jest suite to Vitest. NOT real-browser multi-page journeys (that is `e2e-testing`), NOT pytest suites (that is `testing-py`), NOT accessibility auditing (that is `accessibility`).

- Skill: `ericrisco/testing-web` (Agent Skill, multi-file: 6 files)
- Install (CLI): `npx skillmds@latest add ericrisco/testing-web`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ericrisco/testing-web/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: ericrisco (https://skillmd.com/u/ericrisco)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ericrisco/testing-web

---


# testing-web — fast, trustworthy component and hook tests

A frontend test is only worth keeping if it survives a refactor and fails for the right reason.
The way to get there is boring and non-negotiable: render the thing, query it the way a user finds
it, drive it with real events, and assert on what the user can see. Everything in this skill bends
toward that. Tests that reach into `className`, `state`, props, or instance methods pass while the UI
is broken and break while the UI is fine — delete that instinct.

## What this owns / what it doesn't

This skill owns unit, component, and custom-hook tests that run in a simulated DOM (jsdom) or Vitest
Browser Mode at component granularity. The moment scope crosses a boundary, switch skills:

- Real browser driving a whole app, page navigation, multi-page login-to-dashboard journeys -> [`../e2e-testing/SKILL.md`](../e2e-testing/SKILL.md).
- pytest / fixtures / Python suites -> [`../testing-py/SKILL.md`](../testing-py/SKILL.md).
- axe runs, contrast ratios, keyboard-nav auditing as the *goal* -> [`../accessibility/SKILL.md`](../accessibility/SKILL.md). (You will use role queries here; auditing is not the job.)
- Render/runtime perf, re-render counts, web vitals -> [`../debug/SKILL.md`](../debug/SKILL.md) for diagnosis.
- How to build the component in the first place -> [`../react/SKILL.md`](../react/SKILL.md) or [`../nextjs/SKILL.md`](../nextjs/SKILL.md).

## Pick the runner (do this once, never run both)

| Project shape | Runner | Why |
|---|---|---|
| New Vite / React 19 / Next 16 repo | **Vitest 4** | Shares your `vite.config`, zero second transform pipeline, Browser Mode is stable as of v4.0 (Oct 2025). |
| Established Jest / CRA / React Native repo | **Jest 30** | Migration cost outweighs the win; Jest 30 is current (min Node 18.x, min TS 5.4). |
| Both installed | pick one and rip the other out | Two runners means two configs, two mock APIs, doubled CI — and tests that pass in one, fail in the other. |

Vitest is the de-facto default for new frontend projects in 2026; Jest stays where it already lives.
Jest 30 specifics (ts-jest vs babel, the jsdom v26 `window.location` break) live in
[`references/jest-setup.md`](references/jest-setup.md).

## Minimal Vitest setup that works

Pin current majors: `vitest ^4.0`, `@testing-library/react ^16.3`, `@testing-library/jest-dom ^6.9`,
`@testing-library/user-event ^14.6`, `jsdom`, `@vitejs/plugin-react`.

```ts
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",   // give the test a DOM; default 'node' has no document
    globals: true,          // describe/it/expect without imports; jest-dom matchers register globally
    setupFiles: ["./vitest.setup.ts"],
  },
});
```

```ts
// vitest.setup.ts
import "@testing-library/jest-dom/vitest"; // the /vitest entry — NOT the bare import (that is Jest's)
```

The `/vitest` import path matters: the bare `@testing-library/jest-dom` registers against Jest's
`expect`. Wrong path = `toBeInTheDocument is not a function`.

## The one rule: test what the user sees

Query and assert on the rendered output a human perceives, never the mechanism. This is what makes a
test outlive a refactor — rename a state variable, swap a class library, restructure the tree, and a
behavioral test still passes.

```tsx
// Bad — coupled to internals; passes when broken, breaks when fine
expect(wrapper.find(".btn--loading")).toHaveLength(1);
expect(component.state.isOpen).toBe(true);

// Good — coupled to user-observable behavior
expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled();
expect(screen.getByRole("dialog")).toBeVisible();
```

## Query priority ladder

Reach for the highest query that fits. `getByTestId` is the fire escape, not the front door — it
asserts nothing about accessibility or labels.

| Priority | Query | Use for |
|---|---|---|
| 1 | `getByRole(name)` | Almost everything: buttons, headings, inputs, dialogs, links. |
| 2 | `getByLabelText` | Form fields tied to a `<label>`. |
| 3 | `getByPlaceholderText` | Inputs with only a placeholder (prefer a real label). |
| 4 | `getByText` | Non-interactive copy, paragraphs, list items. |
| 5 | `getByDisplayValue` | Asserting a filled-in input's current value. |
| last | `getByTestId` | Only when no role/label/text identifies the node. |

Pick the right variant by what you expect:

| Variant | Returns | Throws if absent? | Use when |
|---|---|---|---|
| `getBy*` | element now | yes | element must already be there |
| `queryBy*` | element or `null` | no (returns null) | asserting **absence** (`expect(...).toBeNull()`) |
| `findBy*` | Promise of element | rejects after timeout | element **appears later** (after fetch/async) |

Never `getBy` something that arrives asynchronously — it throws before the element mounts. That is what
`findBy` is for.

## Driving interactions

Set up `user-event` once per test and `await` every interaction. It dispatches the full realistic event
sequence (pointerdown -> mousedown -> focus -> mouseup -> click), so it catches handlers `fireEvent`
silently skips.

```tsx
import userEvent from "@testing-library/user-event";

it("submits the typed name", async () => {
  const user = userEvent.setup();           // call setup() before interacting
  render(<Greeter />);
  await user.type(screen.getByLabelText(/name/i), "Ada"); // await — these are async
  await user.click(screen.getByRole("button", { name: /greet/i }));
  expect(screen.getByText(/hello, ada/i)).toBeInTheDocument();
});
```

Reach for `fireEvent` only for events `user-event` has no verb for (e.g. `scroll`). A missing `await`
is the single most common source of "passes locally, flakes in CI."

## Async and the act() warning

"An update to X was not wrapped in act(...)" means state updated after your assertion ran — the test
finished, the component kept working, React complained. The fix in a **component** test is almost never
a manual `act()`. It is to *wait* for the observable result:

```tsx
// Bad — asserts before the fetch resolves; state lands "outside act"
render(<Profile id="1" />);
expect(screen.getByText("Ada")).toBeInTheDocument(); // throws / act warning

// Good — findBy retries until the node appears, inside RTL's act wrapper
render(<Profile id="1" />);
expect(await screen.findByText("Ada")).toBeInTheDocument();
```

For a transition you can't pin to a single element, wrap the assertion in `waitFor`. Bare `act()` in a
component test is a code smell — it belongs to hook tests (next section).

## Testing hooks

`renderHook` ships inside **`@testing-library/react`** itself. Do not install or import the long-deprecated
`@testing-library/react-hooks`. Read live values off `result.current`; wrap any setter call you trigger
yourself in `act()`; re-run with new props via `rerender`; await async settle with `waitFor`.

```tsx
import { renderHook, act, waitFor } from "@testing-library/react";

it("counts down then stops at zero", async () => {
  const { result, rerender } = renderHook(({ from }) => useCountdown(from), {
    initialProps: { from: 3 },
  });
  expect(result.current.value).toBe(3);

  act(() => result.current.start());     // a setter YOU invoke -> wrap in act
  await waitFor(() => expect(result.current.value).toBe(0)); // async settle -> waitFor

  rerender({ from: 10 });                // feed new props
  expect(result.current.value).toBe(10);
});
```

## Mocking the boundary

Mock at the edge your code talks to the outside world — the network or the imported module — never the
internal function you are trying to verify. Mock the unit under test and the test proves nothing.

- **Network**: prefer **MSW** (`http.get(...)` handlers) so components hit a real `fetch`/`axios` path. It survives client-library swaps.
- **A whole module**: `vi.mock("./api")` (Vitest) / `jest.mock("./api")` (Jest) for non-network collaborators.
- **Time**: `vi.useFakeTimers()` for timers/debounce; advance with `vi.advanceTimersByTime(ms)`, then restore in cleanup.

```ts
import { vi } from "vitest";
vi.mock("./flags", () => ({ isEnabled: () => true })); // a boundary module, not the component
```

Runnable copy-paste recipes — form submit, controlled input, MSW async data, a provider-wrapping custom
`render`, fake timers, a hook with an effect + cleanup, an error-boundary test — live in
[`references/recipes.md`](references/recipes.md).

## Snapshots vs assertions

Default to explicit behavioral assertions. A snapshot proves nothing about correctness — it proves output
didn't change, and a giant DOM snapshot gets blindly `--updated` the first time it breaks. Snapshot only
small, stable, serializable output (a formatted currency string, a normalized config object). Never
snapshot a full component tree as your primary assertion.

## Mutation: does the suite actually notice?

Coverage tells you which code ran. It cannot tell you whether any test would have **noticed** if that code were wrong — and a `render()` with no assertion, or a snapshot nobody reads, raises coverage while detecting nothing. Mutation testing plants bugs on purpose: if the suite still passes, the mutant *survived* and you have found a test that asserts nothing.

```jsonc
// stryker.config.json — scope is not optional here
{ "testRunner": "vitest", "mutate": ["src/cart/total.ts", "src/cart/discount.ts"] }
```

```bash
npx stryker run
```

Reach for the real tool over a hand-written mutant list: Stryker generates mutants from the syntax tree, so it cannot apply one to code that moved and cannot report one it never ran.

- **Always scope `mutate` to the files you changed.** A whole-project Stryker run on a real front-end does not finish in a useful amount of time; that is the main reason teams try it once and abandon it.
- **Scale it to risk.** Not a default toll. Run it on the logic where a bug is expensive — pricing, totals, permissions, anything money- or auth-shaped — not on presentational components, where a surviving mutant usually just means the DOM detail genuinely does not matter.
- **A survivor is not automatically a failure.** Some mutants are semantically equivalent to the original and cannot be killed; classify those with the reason. Never add an assertion about non-behaviour just to kill one — that is coverage-chasing wearing a different hat.
- **A survivor that is a real bug gets an assertion, not an excuse.** Write it, then rerun.

## Anti-patterns

| Anti-pattern | Why it's wrong | Do instead |
|---|---|---|
| `getByTestId` as first choice | Asserts nothing about a11y or labels; survives broken markup | Climb the ladder: role > label > text first |
| `getBy*` for async content | Throws before the element mounts | `await findBy*` / `await waitFor(...)` |
| Interaction without `await` | Assertion runs before the event settles; flakes in CI | `await user.click(...)` every time |
| `fireEvent.click` by default | Skips the realistic pointer/focus sequence | `userEvent.setup()` then `await user.click` |
| Manual `act()` in a component test | Masks the real fix (waiting for output) | Await `findBy`/`waitFor` instead |
| Asserting on `state`/`props`/`className` | Couples the test to internals; breaks on refactor | Assert on rendered role/text the user sees |
| `setTimeout`/`sleep` to wait | Arbitrary delay = slow + still flaky | `findBy`/`waitFor` retries until ready |
| Mocking the unit under test | The test verifies the mock, not the code | Mock the network/module boundary only |
| Importing `@testing-library/react-hooks` | Deprecated; folded into `@testing-library/react` | Import `renderHook` from `@testing-library/react` |
| Bare `@testing-library/jest-dom` in Vitest | Registers against Jest's expect -> matcher missing | Import `@testing-library/jest-dom/vitest` |
| Running Jest and Vitest in one suite | Two configs/mock APIs; passes in one, fails in other | Pick one runner, remove the other |
| A test file with zero `expect(...)` | Renders but verifies nothing; green by accident | Every test asserts an observable outcome |

## Verify your suite

Run the linter against a test file or directory to catch these shape violations before review:

```bash
scripts/verify.sh src/components/__tests__
```

It hard-fails on tests with no assertion and on un-awaited interactions, and warns on testid-first
queries, raw `fireEvent`, stray `act()` in component files, and `setTimeout`-based waiting. It checks
artifact *shape*, not whether your assertions are true.

