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