unit-testing

>-

petrkindlmann/qa-skills424 installsMITSynced Aug 22

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
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

← All Testing skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY