Skip to main content

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`).

Zur Installation springen

Quellinformationen

Repository
ericrisco/rsc-harness
Letzte Quellaktivität
18. August 2026 um 07:58
Erkannte Sprache von SKILL.md
Englisch
Sterne
110
Forks
9

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

Datei-Explorer
6 Dateien

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
testing-web
description
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`).
tags
["testing","frontend","vitest","jest","testing-library","react","hooks","component-testing","jsdom"]
recommends
["e2e-testing","accessibility","testing-py","react","nextjs","debug"]
origin
risco
# 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.
Auf GitHub ansehen