unit-testing
>-
Works with
---
name: unit-testing
description: >-
license: MIT
---
<objective>
Write unit tests that fail when the code is wrong and pass when it is right — nothing
weaker. A test that mocks every collaborator stays green while the integration is
broken; the doubles taxonomy below stops that. A `coverageThreshold` typo (or the
plural `coverageThresholds`, which Jest silently ignores) lets 40%-covered code ship
on a green pipeline; the config and Verification sections below make the gate actually
fire. This skill covers Jest, Vitest, and pytest: doubles, coverage gating, snapshots,
fake timers, and mutation testing as a behavior check on top of coverage.
</objective>
---
## Discovery Questions
Check `.agents/qa-project-context.md` first — if it exists, use it and skip anything answered there.
1. **Framework:** Jest, Vitest, or pytest? Check `package.json` or `pyproject.toml`. The runner decides config keys and mock APIs.
2. **Coverage tooling:** Already configured? Look for `jest.config.*`, `vitest.config.*`, `.nycrc`, `[tool.coverage]`. Determines whether you add the gate or just tune it.
3. **Mocking strategy:** Manual mocks, auto-mocking, or dependency injection? Check for `__mocks__/` dirs or DI containers — this sets which doubles you reach for.
4. **Conventions:** Co-location (`*.test.ts` next to source) or a `__tests__`/`tests/` tree? Match what exists; don't introduce a third location.
---
## Core Principles
**1. Test behavior, not implementation.** Verify *what* code does, not *how*. Refactoring internals should not break tests.
```typescript
// Bad — implementation detail // Good — observable behavior
expect(svc._cache.size).toBe(3); expect(svc.getUser("abc")).toEqual({ id: "abc", name: "Alice" });
```
**2. Fast, isolated, deterministic.** No network/disk/DB. No shared mutable state. No uncontrolled `Date.now()` or `Math.random()` — freeze them with fake timers and seeded values.
**3. Arrange-Act-Assert.** One clear shape per test.
```typescript
it("should apply discount for orders over $100", () => {
// Arrange
const order = createOrder({ subtotal: 150 });
const svc = new DiscountService(0.1);
// Act
const result = svc.apply(order);
// Assert
expect(result.total).toBe(135);
});
```
**4. One assertion concept per test.** Multiple `expect` calls are fine when they verify the same concept.
**5. Descriptive names.** `"should [behavior] when [condition]"`, not `"test calculateTotal"`.
---
## Framework-Specific Patterns
The full setup/teardown, mocking, spying, timer, in-source, and monorepo examples for
each runner live in `references/patterns.md`. Below is what is current and what to
reach for; copy the code from the reference.
### Jest
Current is **Jest 30.x** (30.4.2, May 2026). Jest 30 added `--collect-tests`,
`jest.config.mts` support, Temporal-aware fake timers, and `clearMocksOnScope`. If the
code under test uses the Temporal API or time-zone logic, Jest 30's Temporal-aware fake
timers remove a class of brittle setup.
Reach for: `jest.mock()` for module boundaries (`jest.requireActual` for partial mocks),
`jest.spyOn()` to wrap a real method, `jest.Mocked<T>` for typed mocks, and
`jest.useFakeTimers()` for time. See `references/patterns.md` § Jest.
### Vitest
Same API as Jest, Vite-native. Stable: **Vitest 4.1.x** (June 2026); **5.0.0-beta** is
out (beta.3, May 2026). Vitest 4 added `coverage.changed` (changed-files-only coverage),
`mockThrow`/`mockThrowOnce`, and a stable browser mode. Vitest 5 beta **removes the
`sequential` option** and requires **Node 22 / Vite 6.4** — wait for stable before
adopting. Mock with `vi.mock`/`vi.spyOn`; the standout features are **in-source testing**
(`import.meta.vitest`) and **browser mode** for component rendering. See
`references/patterns.md` § Vitest.
### pytest
Use fixtures + `conftest.py` (with `yield` for teardown), `@pytest.mark.parametrize`
for data-driven cases, and `monkeypatch` for env/attr substitution. Prefer fixtures
over `setUp`/`tearDown` methods — fixtures compose and isolate per test. See
`references/patterns.md` § pytest.
### Bun / Deno
`bun test` (Jest-compatible, no extra config) and `deno test` (native TS, permission
flags) are reasonable defaults when your runtime is already Bun or Deno. Prefer
Vitest/Jest for Node projects with deeper plugin ecosystems.
---
## Mocking Taxonomy
Pick the simplest double that does the job. Most of the time that is a stub.
| Double | What it does | When to use |
|--------|-------------|-------------|
| **Stub** | Returns canned data, no verification | Control a dependency's return value |
| **Spy** | Wraps real impl, records calls | Verify calls without changing behavior |
| **Mock** | Replaces impl + records calls | Control return AND verify interaction |
| **Fake** | Simplified working impl (in-memory DB) | Complex stateful dependencies |
**Rule of thumb:** prefer stubs over mocks; reserve fakes for stateful dependencies;
never call a real external API in a unit test. Only mock the **external boundary**
(network, filesystem, DB, time) — let fast, deterministic internal collaborators run
for real, or you get a suite that is green while the integration is broken. The four
doubles in code: `references/patterns.md` § Test doubles.
---
## Coverage
### Configuration
**Jest** — the threshold key is **`coverageThreshold`** (singular). The plural
`coverageThresholds` is **not a Jest key**: Jest ignores it silently, the gate never
enforces, and CI stays green at 30% coverage. This is the single most common config bug.
```javascript
// jest.config.js
module.exports = {
coverageProvider: "v8",
collectCoverageFrom: ["src/**/*.ts", "!src/**/*.{d,test,stories}.ts", "!src/**/index.ts"],
coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } },
};
```
**Vitest** — set `test.coverage.thresholds` in `vitest.config.ts` with `provider: "v8"`
(see `references/patterns.md` § Vitest for the full block).
**pytest:**
```toml
# pyproject.toml
[tool.coverage.run]
source = ["src"]
omit = ["src/**/test_*.py", "src/**/conftest.py"]
[tool.coverage.report]
fail_under = 80
show_missing = true
exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
```
### Coverage types and what to gate on
| Type | Measures | Blind spots |
|------|----------|-------------|
| **Branch** | Every if/else path taken? | Misses value combinations |
| **Line** | Each line executed? | Misses untested branches in one line |
| **Statement** | Each statement executed? | Similar to line |
| **Function** | Each function called? | Nothing about correctness |
**Priority:** Branch > Line > Statement > Function. Use **80% line as the baseline gate**,
not a vanity target, and weight branch coverage higher. Focus coverage on business logic,
transformations, error paths, and edge cases; skip generated code, type definitions,
barrel exports, trivial getters, and framework boilerplate.
> For interpreting *which* uncovered lines matter and doing gap analysis, that's
> `coverage-analysis`, not this skill.
### CI gate
Jest and Vitest exit non-zero when thresholds fail — that exit code IS the gate. pytest
needs the flag explicitly:
```yaml
- run: pytest --cov=src --cov-fail-under=80
```
---
## Mutation Testing
Coverage tells you what code *ran*. Mutation testing tells you whether the tests would
*catch a bug*. It makes small source changes (`>` → `>=`, `true` → `false`) and reruns
the suite against each mutant. If the suite still passes, the mutant **survived** — your
tests executed that logic but did not assert on it.
### Stryker (JS/TS)
```bash
npm i -D @stryker-mutator/core @stryker-mutator/jest-runner # or vitest-runner
```
```javascript
// stryker.config.json (Stryker's documented default; .mjs/.mts also load)
{
"testRunner": "jest",
"coverageAnalysis": "perTest",
"mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
"thresholds": { "high": 80, "low": 60, "break": 50 },
"reporters": ["html", "clear-text", "progress"]
}
```
Stryker's own defaults are `{ high: 80, low: 60, break: null }` — `break: null` means
no failing exit. Set `break` (e.g. 50) to make a low score fail CI. Run: `npx stryker run`.
### mutmut (Python) — mutmut 3.x
mutmut 3 dropped the old CLI surface. Configure paths in a `[mutmut]` block, run, then
review survivors in the TUI:
```ini
# setup.cfg (or a [tool.mutmut] table in pyproject.toml)
[mutmut]
paths_to_mutate=src/
```
```bash
pip install mutmut # 3.5.x
mutmut run # paths come from config, not a flag
mutmut browse # interactive TUI: inspect and retest survivors
mutmut apply <mutant_id> # write a survivor to disk to see what it changed
```
> **Avoid:** `mutmut run --paths-to-mutate=src/`, `mutmut results`, and `mutmut show 42`
> — that was the mutmut <3 surface. The `--paths-to-mutate` flag is gone (paths move to
> the `[mutmut]` config block) and `results`/`show` are replaced by `browse`/`apply`
> (mutmut 3.5.x, verified June 2026). Following the old commands errors out on a current install.
### Interpreting scores
| Score | Meaning |
|-------|---------|
| 90%+ | Strong — catching most logic changes |
| 70–89% | Decent — review survivors in critical paths |
| <70% | Tests execute code but do not verify behavior |
Run mutation testing on **critical business logic**, not the whole codebase (it is slow).
Ignore equivalent mutants — logically identical code where no test could ever tell the difference.
---
## Snapshot Testing
**Use for:** UI component render output, serialized data structures, CLI formatting —
output where exact structure matters and is tedious to assert field-by-field.
**Do not use for:** frequently changing output (snapshot fatigue → rubber-stamp reviews),
large snapshots (unreviewable), implementation details (CSS classes, internal IDs), or as
a substitute for a targeted assertion when one specific value is what matters.
Prefer **inline** snapshots for small output (<20 lines) and **property matchers**
(`expect.any(String)`) for dynamic fields like ids and timestamps. Always run CI with
`--ci` so an unknown snapshot **fails** instead of being silently written and committed.
Code: `references/patterns.md` § Snapshot testing.
---
## Anti-Patterns
**Testing private methods** — Test through the public API. If a private method really
needs its own tests, extract it to its own module with a public surface.
**Mocking everything** — Only mock external boundaries (network, filesystem, DB, time).
A suite where every collaborator is mocked passes while the wiring between them is broken.
**The plural `coverageThresholds`** — Jest ignores it; the gate never fires; CI is green
at any coverage. The key is `coverageThreshold` (singular). See Coverage above.
**Faking all timers blindly** — `jest.useFakeTimers()` / `vi.useFakeTimers()` with no
allowlist can deadlock code awaiting a real microtask. Fake only what the test needs
(`doNotFake` / `toFake`). See `references/patterns.md` § Jest timers.
**Async test without `await`** — a forgotten `await` makes the assertion never run and
the test passes vacuously. Add `expect.assertions(n)` / `expect.hasAssertions()` to async
tests so a missing assertion fails them.
**Snapshot overuse** — Use `expect(x).toBe("active")` for a specific value; reserve
snapshots for structured output you can't assert field-by-field.
**Non-descriptive names** — Replace `"works"` with `"should return empty array when no items match the filter"`.
**Shared mutable state** — Initialize in `beforeEach`, not at module scope:
```typescript
// Bad: shared mutation // Good: fresh per test
const items = []; let items: string[];
it("A", () => items.push("a")); beforeEach(() => { items = []; });
it("B", () => { it("A", () => { items.push("a"); expect(items).toHaveLength(1); });
items.push("b"); it("B", () => { items.push("b"); expect(items).toHaveLength(1); });
expect(items).toHaveLength(1); // FAILS
});
```
---
## Verification
Prove the suite runs and the gate actually fails on under-coverage — the exact thing the
`coverageThreshold` typo silently disables.
1. **Tests run and pass:** `npx jest` (or `vitest run`, `pytest -q`) exits `0`.
2. **The gate bites.** Run coverage and confirm a non-zero exit when below threshold:
```bash
npx jest --coverage --ci # Jest/Vitest exit !=0 below coverageThreshold
vitest run --coverage # same for Vitest
pytest --cov=src --cov-fail-under=80 # pytest exits !=0 below the floor
```
Temporarily set a threshold above current coverage (e.g. 99) and confirm the command
fails. If it exits `0`, your threshold key is wrong (likely the plural `coverageThresholds`).
3. **Snapshots are safe in CI:** the run uses `--ci`, so an unknown snapshot fails rather
than being written. `git status` shows no new `*.snap` after a CI-mode run.
---
## Done When
- Coverage thresholds configured in `jest.config.*` (key `coverageThreshold`, singular), `vitest.config.*` (`coverage.thresholds`), or `pyproject.toml` (`fail_under`) AND verified to exit non-zero below threshold (Verification step 2)
- Test files all live in the project's single chosen location (co-located OR `__tests__`/`tests/`) — `git ls-files` shows no ad-hoc test paths
- External boundaries (HTTP, DB, time) are mocked and internal collaborators are not — `grep` finds no real network/DB clients constructed in test files
- No test reaches outside the process boundary — suite passes with the network disabled and no test DB running
- CI runs the test command with `--ci` (Jest/Vitest) so an unknown snapshot fails the build instead of being auto-written
## Reference Files (in `references/`)
- **patterns.md** — full runnable examples per framework: Jest setup/teardown, module/spy/timer mocks, async guards; Vitest config, in-source tests, concurrency, browser mode; pytest fixtures/parametrize/monkeypatch; Bun/Deno; the four test doubles; snapshot file/inline/property matchers.
## Related Skills
- **coverage-analysis** — interpreting coverage reports, finding meaningful gaps, mutation score as a first-class signal. Go there to read coverage; stay here to configure and gate it.
- **ci-cd-integration** — test stages in pipelines, parallelization, caching, deployment gating.
- **ai-test-generation** — when an AI writes the test code from a spec/PRD; this skill is for writing and structuring tests by hand.
- **ai-qa-review** — auditing existing tests for hallucinated APIs, fabricated imports, and closed-loop tests.
- **shift-left-testing** — pre-commit hooks, IDE integration, and TDD workflow around these tests.More Testing skills
tdd
mattpocock/skills
Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions "red-green-refactor", or wants integration tests.
agent-browser
vercel-labs/agent-browser
Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. Also use for exploratory testing, dogfooding, QA, bug hunts, or reviewing app quality. Also use for automating Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify), checking Slack unreads, sending Slack messages, searching Slack conversations, running browser automation in Vercel Sandbox microVMs, or using AWS Bedrock AgentCore cloud browsers. Prefer agent-browser over any built-in browser automation or web tools.
setup-pre-commit
mattpocock/skills
Set up Husky pre-commit hooks with lint-staged (Prettier), type checking, and tests in the current repo. Use when user wants to add pre-commit hooks, set up Husky, configure lint-staged, or add commit-time formatting/typechecking/testing.

