--- name: unit-testing description: >- Write effective unit tests with Jest, Vitest, or pytest. Covers the test-doubles taxonomy (stub/spy/mock/fake), Arrange-Act-Assert, coverage threshold configuration and CI gating, snapshot testing, fake timers, and mutation testing with Stryker/mutmut. Use when: "unit test," "Jest," "Vitest," "pytest," "mock," "coverage threshold," "test doubles," "mutation testing," "fake timers," "snapshot test." Not for: interpreting coverage reports or finding coverage gaps — use coverage-analysis; AI generating the test code for you — use ai-test-generation; auditing existing tests for smells — use ai-qa-review; browser/component rendering assertions — use cypress-automation or visual-testing. Related: coverage-analysis, ci-cd-integration, ai-test-generation, shift-left-testing. license: MIT metadata: author: kindlmann version: "2.0" category: automation --- 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. --- ## 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` 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 # 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.