mcpbeat Sign in

Testing Web Agent Skill

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

7k tokens
context cost
the whole folder, loaded on every use
6
files
ships runnable scripts
0
copies elsewhere
how many repositories repackaged it
105
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/ericrisco/rsc-harness --skill testing-web

What comes with it

15 569 bytes besides the instruction
evals/README.md
evals/cases.yaml
references/jest-setup.md
references/recipes.md
scripts/verify.sh

The instruction itself

14 sections, as written by the author

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.
  • pytest / fixtures / Python suites -> ../testing-py/SKILL.md.
  • axe runs, contrast ratios, keyboard-nav auditing as the *goal* -> ../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 for diagnosis.
  • How to build the component in the first place -> ../react/SKILL.md or ../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.

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.

// 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"],
  },
});
// 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.

// 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.

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:

// 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.

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

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.

// stryker.config.json — scope is not optional here
{ "testRunner": "vitest", "mutate": ["src/cart/total.ts", "src/cart/discount.ts"] }
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:

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.

How to use it

Copy the folder

Take ericrisco/testing-web from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.

Install what it needs

The instructions reference npx. Without those the skill loads but fails at the first command.