<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.
- Framework: Jest, Vitest, or pytest? Check or . The runner decides config keys and mock APIs.
- Coverage tooling: Already configured? Look for , , , . Determines whether you add the gate or just tune it.
- Mocking strategy: Manual mocks, auto-mocking, or dependency injection? Check for dirs or DI containers — this sets which doubles you reach for.
- Conventions: Co-location ( next to source) or a / 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
or
— 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
calls are fine when they verify the same concept.
5. Descriptive names. "should [behavior] when [condition]"
, not
.
Framework-Specific Patterns
The full setup/teardown, mocking, spying, timer, in-source, and monorepo examples for
each runner live in
. 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
,
support, Temporal-aware fake timers, and
. 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:
for module boundaries (
for partial mocks),
to wrap a real method,
for typed mocks, and
for time. See
§ 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
(changed-files-only coverage),
/
, and a stable browser mode. Vitest 5 beta
removes the
option and requires
Node 22 / Vite 6.4 — wait for stable before
adopting. Mock with
/
; the standout features are
in-source testing
(
) and
browser mode for component rendering. See
§ Vitest.
pytest
Use fixtures +
(with
for teardown),
for data-driven cases, and
for env/attr substitution. Prefer fixtures
over
/
methods — fixtures compose and isolate per test. See
§ pytest.
Bun / Deno
(Jest-compatible, no extra config) and
(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:
§ Test doubles.
Coverage
Configuration
Jest — the threshold key is
(singular). The plural
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
in
with
(see
§ 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
, 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 (
→
,
→
) 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 }
—
means
no failing exit. Set
(e.g. 50) to make a low score fail CI. Run:
.
mutmut (Python) — mutmut 3.x
mutmut 3 dropped the old CLI surface. Configure paths in a
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/
,
, and
— that was the mutmut <3 surface. The
flag is gone (paths move to
the
config block) and
/
are replaced by
/
(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
(
) for dynamic fields like ids and timestamps. Always run CI with
so an unknown snapshot
fails instead of being silently written and committed.
Code:
§ 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 — Jest ignores it; the gate never fires; CI is green
at any coverage. The key is
(singular). See Coverage above.
Faking all timers blindly —
/
with no
allowlist can deadlock code awaiting a real microtask. Fake only what the test needs
(
/
). See
§ Jest timers.
Async test without — a forgotten
makes the assertion never run and
the test passes vacuously. Add
/
to async
tests so a missing assertion fails them.
Snapshot overuse — Use
for a specific value; reserve
snapshots for structured output you can't assert field-by-field.
Non-descriptive names — Replace
with
"should return empty array when no items match the filter"
.
Shared mutable state — Initialize in
, 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
typo silently disables.
- Tests run and pass: (or , ) exits .
- 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 , your threshold key is wrong (likely the plural ).
- Snapshots are safe in CI: the run uses , so an unknown snapshot fails rather
than being written. shows no new after a CI-mode run.
Done When
- Coverage thresholds configured in (key , singular), (), or () 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 /) — shows no ad-hoc test paths
- External boundaries (HTTP, DB, time) are mocked and internal collaborators are not — 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 (Jest/Vitest) so an unknown snapshot fails the build instead of being auto-written
Reference Files (in )
- 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.