unit-testing · diff

v1.0 to v2.0

178 added, 291 removed. Audit A to A.

---
name: unit-testing
description: >-
- Write effective unit tests with Jest, Vitest, or pytest. Covers mocking strategies
- (stubs, spies, mocks, fakes), coverage configuration and meaningful thresholds,
- snapshot testing, mutation testing with Stryker/mutmut, test doubles taxonomy,
- and the Arrange-Act-Assert pattern. Use when: "unit test," "Jest," "Vitest,"
- "pytest," "mock," "coverage," "test doubles," "mutation testing."
+ 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: "1.0"
+ version: "2.0"
category: automation
---
<objective>
- Write effective, maintainable unit tests using Jest, Vitest, or pytest.
+ 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
- 1. **Framework:** Jest, Vitest, or pytest? Check `package.json` or `pyproject.toml`.
- 2. **Coverage tooling:** Already configured? Look for `jest.config.*`, `vitest.config.*`, `.nycrc`, `[tool.coverage]`.
- 3. **Mocking strategy:** Manual mocks, auto-mocking, or dependency injection? Check for `__mocks__/` dirs or DI containers.
- 4. **Existing conventions:** Check `.agents/qa-project-context.md` first for project-specific guidelines.
+ 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()`.
+ **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.**
+ **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 if they verify the same concept).
+ **4. One assertion concept per test.** Multiple `expect` calls are fine when they verify the same concept.
- **5. Descriptive test names:** `"should [behavior] when [condition]"`, not `"test calculateTotal"`.
+ **5. Descriptive names.** `"should [behavior] when [condition]"`, not `"test calculateTotal"`.
---
## Framework-Specific Patterns
- ### Jest
-
- > Current is **Jest 30.x** (30.4.2, May 2026). Jest 30 introduced `--collect-tests`, `jest.config.mts` support, Temporal API support in fake timers, and `clearMocksOnScope`. If your code under test uses the Temporal API or time-zone logic, Jest 30's Temporal-aware fake timers remove a class of brittle test setup.
-
- **describe/it structure with setup/teardown:**
-
- ```typescript
- describe("UserService", () => {
- let service: UserService;
- let mockRepo: jest.Mocked<UserRepository>;
-
- beforeEach(() => {
- mockRepo = { findById: jest.fn(), save: jest.fn() } as jest.Mocked<UserRepository>;
- service = new UserService(mockRepo);
- });
- afterEach(() => jest.restoreAllMocks());
-
- it("should return user when found", async () => {
- mockRepo.findById.mockResolvedValue({ id: "1", name: "Alice" });
- const result = await service.getUser("1");
- expect(result).toEqual({ id: "1", name: "Alice" });
- });
-
- it("should throw when user not found", async () => {
- mockRepo.findById.mockResolvedValue(null);
- await expect(service.getUser("999")).rejects.toThrow(NotFoundError);
- });
- });
- ```
-
- **Module mocking (`jest.mock`):**
-
- ```typescript
- jest.mock("./email-client", () => ({
- sendEmail: jest.fn().mockResolvedValue({ sent: true }),
- }));
- // Partial mock — keep original, override one export
- jest.mock("./utils", () => ({ ...jest.requireActual("./utils"), generateId: jest.fn(() => "fixed") }));
- ```
-
- **Spying (`jest.spyOn`):** wraps real method, records calls.
-
- ```typescript
- const spy = jest.spyOn(console, "warn").mockImplementation();
- service.deprecatedMethod();
- expect(spy).toHaveBeenCalledWith(expect.stringContaining("deprecated"));
- ```
-
- **Timer mocking:**
-
- ```typescript
- beforeEach(() => jest.useFakeTimers());
- afterEach(() => jest.useRealTimers());
+ 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.
- it("should debounce", () => {
- const fn = jest.fn();
- const debounced = debounce(fn, 300);
- debounced();
- expect(fn).not.toHaveBeenCalled();
- jest.advanceTimersByTime(300);
- expect(fn).toHaveBeenCalledTimes(1);
- });
- ```
+ ### Jest
- **Async:** `await expect(fn()).resolves.toEqual(...)` / `await expect(fn()).rejects.toThrow(...)`.
+ 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 but Vite-native. Current stable: **Vitest 4.1.x** (May 2026); 5.0.0-beta.2 published. Vitest 4 introduced `coverage.changed` (changed-files-only coverage), BlazeDiff replacing pixelmatch in browser mode, and `mockThrow`/`mockThrowOnce`. Vitest 5 beta removes the `sequential` option and changes locator representation — wait for stable before adopting.
-
- ```typescript
- // vitest.config.ts
- import { defineConfig } from "vitest/config";
- export default defineConfig({
- test: {
- globals: true,
- environment: "node",
- coverage: {
- provider: "v8",
- reporter: ["text", "html", "lcov"],
- // changed: true, // Vitest 4.1+: report coverage only for files in the diff — major CI win on big repos
- },
- },
- });
- ```
-
- **Mocking with `vi`:**
-
- ```typescript
- vi.mock("./email-client", () => ({ sendConfirmation: vi.fn().mockResolvedValue(true) }));
- const spy = vi.spyOn(repository, "save");
- ```
-
- **In-source testing** (useful for utilities):
-
- ```typescript
- export function clamp(val: number, min: number, max: number) {
- return Math.min(Math.max(val, min), max);
- }
- if (import.meta.vitest) {
- const { it, expect } = import.meta.vitest;
- it("clamps below", () => expect(clamp(-5, 0, 10)).toBe(0));
- it("clamps above", () => expect(clamp(15, 0, 10)).toBe(10));
- }
- ```
-
- Enable: `test: { includeSource: ["src/**/*.ts"] }` and `define: { "import.meta.vitest": "undefined" }`.
-
- **Monorepo workspaces:**
-
- ```typescript
- // vitest.workspace.ts
- export default ["packages/*/vitest.config.ts"];
- ```
-
- **Browser mode** (Vitest 4+): runs component-level tests in a real browser (Playwright/WebdriverIO under the hood) instead of JSDOM. Replaces JSDOM-based component tests for many cases and overlaps with Cypress component testing. Use when JSDOM gives you false positives on layout, focus, or paint behavior.
-
- ```typescript
- // vitest.config.ts (browser mode)
- import { defineConfig } from 'vitest/config';
- export default defineConfig({
- test: {
- browser: { enabled: true, provider: 'playwright', name: 'chromium' },
- },
- });
- ```
-
- ### Bun test / Deno test
-
- For Bun-stack projects, **`bun test`** is now stable enough for greenfield use — Jest-compatible API, esbuild-fast, no separate runner config needed (`package.json` `scripts.test`). For Deno projects, **`deno test`** is the built-in option with permission flags + native TypeScript. Both are reasonable defaults if your runtime is already Bun or Deno; prefer Vitest/Jest for Node projects with deeper plugin ecosystems.
-
- ---
+ 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
- **Fixtures and conftest.py:**
-
- ```python
- # conftest.py
- @pytest.fixture
- def db():
- database = Database(":memory:")
- database.migrate()
- yield database
- database.close()
-
- @pytest.fixture
- def user_service(db):
- return UserService(db)
- ```
-
- ```python
- class TestUserService:
- def test_create_returns_id(self, user_service):
- uid = user_service.create({"name": "Alice"})
- assert uid is not None
-
- def test_get_nonexistent_raises(self, user_service):
- with pytest.raises(UserNotFoundError):
- user_service.get("nonexistent")
- ```
-
- **Parametrize for data-driven tests:**
-
- ```python
- @pytest.mark.parametrize("input_val,expected", [
- ("hello world", "Hello World"), ("", ""), ("CAPS", "Caps"),
- ])
- def test_title_case(input_val, expected):
- assert title_case(input_val) == expected
- ```
-
- **Monkeypatch for mocking:**
-
- ```python
- def test_uses_env(monkeypatch):
- monkeypatch.setenv("APP_URL", "https://test.local")
- assert fetch_config()["source"] == "https://test.local"
+ 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.
- def test_retry(monkeypatch):
- calls = {"n": 0}
- def fake(url):
- calls["n"] += 1
- if calls["n"] < 3: raise ConnectionError
- return {"ok": True}
- monkeypatch.setattr("app.client.http_request", fake)
- assert fetch_with_retry("https://api.test") == {"ok": True}
- ```
+ ### Bun / Deno
- **Markers:** `@pytest.mark.slow`, then run `pytest -m "not slow"`. Use `-k "test_create"` for name matching.
+ `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 dependency return values |
+ | **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 |
- ```typescript
- // Stub — just a return value
- const pricing = { getPrice: () => 9.99 };
-
- // Spy — real behavior, tracked
- const spy = vi.spyOn(logger, "info");
-
- // Mock — replaced + verified
- const notifier = { send: vi.fn().mockResolvedValue(true) };
- expect(notifier.send).toHaveBeenCalledWith(expect.objectContaining({ type: "done" }));
-
- // Fake — working substitute
- class FakeRepo implements UserRepository {
- private data = new Map<string, User>();
- async findById(id: string) { return this.data.get(id) ?? null; }
- async save(u: User) { this.data.set(u.id, { ...u }); }
- }
- ```
-
- **Rule of thumb:** Use the simplest double. Prefer stubs over mocks. Reserve fakes for stateful dependencies. Never call real external APIs in unit tests.
+ **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:**
+ **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"],
- coverageThresholds: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } },
+ coverageThreshold: { global: { branches: 80, functions: 80, lines: 80, statements: 80 } },
};
```
- **Vitest:** set `test.coverage` in `vitest.config.ts` with `provider: "v8"`, `thresholds: { branches: 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
+ ### 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.
+ **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.
- ### Meaningful Thresholds
+ > For interpreting *which* uncovered lines matter and doing gap analysis, that's
+ > `coverage-analysis`, not this skill.
- - **80% line coverage** as baseline gate, not a vanity target.
- - Branch coverage matters more than line coverage.
- - Focus on: business logic, transformations, error paths, edge cases.
- - Skip: generated code, type definitions, barrel exports, trivial getters, framework boilerplate.
+ ### CI gate
- ### CI Gate
+ Jest and Vitest exit non-zero when thresholds fail — that exit code IS the gate. pytest
+ needs the flag explicitly:
```yaml
- # Jest/Vitest exit non-zero when thresholds fail. For pytest:
- run: pytest --cov=src --cov-fail-under=80
```
---
## Mutation Testing
- Coverage tells you what code *ran*. Mutation testing tells you if tests would *catch a bug*.
-
- It works by making small source changes (e.g., `>` to `>=`, `true` to `false`), running tests against each mutant. If tests still pass, the mutant **survived** -- your tests missed that logic.
+ 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.mjs
- export default {
- testRunner: "jest",
- coverageAnalysis: "perTest",
- mutate: ["src/**/*.ts", "!src/**/*.test.ts"],
- thresholds: { high: 80, low: 60, break: 50 },
- reporters: ["html", "clear-text", "progress"],
- };
+ // 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"]
+ }
```
- Run: `npx stryker run`
+ 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 (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
- mutmut run --paths-to-mutate=src/
- mutmut results # summary
- mutmut show 42 # inspect surviving mutant #42
+ 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
```
- ### Interpreting Scores
+ > **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 |
+ | 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 entire codebases. Ignore equivalent mutants (logically identical code).
+ 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
- ### When to Use
-
- - UI component render output, serialized data structures, CLI formatting
- - Output where exact structure matters and is hard to assert field-by-field
-
- ### When NOT to Use
-
- - Frequently changing output (snapshot fatigue, rubber-stamp reviews)
- - Large snapshots (hard to review), implementation details (CSS classes, internal IDs)
- - As substitute for targeted assertions when specific values matter
+ **Use for:** UI component render output, serialized data structures, CLI formatting —
+ output where exact structure matters and is tedious to assert field-by-field.
- ### File vs Inline Snapshots
+ **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.
- ```typescript
- // File snapshot — stored in __snapshots__/*.snap
- expect(tree).toMatchSnapshot();
+ 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.
- // Inline snapshot — stored in the test file, auto-updated
- expect(tree).toMatchInlineSnapshot(`<header><h1>Dashboard</h1></header>`);
- ```
+ ---
- Prefer inline for small output (<20 lines). Use property matchers for dynamic values:
+ ## Anti-Patterns
- ```typescript
- expect(user).toMatchSnapshot({ id: expect.any(String), createdAt: expect.any(Date) });
- ```
+ **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.
- ## Anti-Patterns (with Fixes)
+ **The plural `coverageThresholds`** — Jest ignores it; the gate never fires; CI is green
+ at any coverage. The key is `coverageThreshold` (singular). See Coverage above.
- **Testing private methods** -- Test through the public API instead. If a private method needs its own tests, extract it to its own module.
+ **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.
- **Mocking everything** -- Only mock external boundaries (network, filesystem, DB, time). Let fast, deterministic internal collaborators use real implementations.
+ **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 specific values. Reserve snapshots for structured output.
+ **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"`.
+ **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:
+ **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); });
+ 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.*`, `vitest.config.*`, or `pyproject.toml` and enforced as a CI gate (non-zero exit on failure)
- - Test files follow the project's co-location or `__tests__` directory convention consistently — no test files in ad-hoc locations
- - Mocking strategy documented (in `qa-project-context.md` or inline): which boundaries get mocked (HTTP, DB, time) and which internal collaborators use real implementations
- - No test reaches outside the process boundary — no real HTTP calls, no real database, no filesystem writes to shared state
- - All snapshot tests are intentional and reviewed: no auto-accepted snapshots with `--updateSnapshot` in CI
+ - 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, mutation testing as a first-class quality signal, identifying meaningful gaps, CI integration.
- - **ci-cd-integration** -- Test stages in pipelines, parallelization, caching, deployment gating.
- - **ai-test-generation** -- AI-assisted test generation, edge case discovery, legacy code bootstrapping.
- - **ai-qa-review** -- Reviewing AI-generated tests for hallucinated APIs, fabricated imports, and closed-loop tests.
- - **shift-left-testing** -- Pre-commit hooks, IDE integration, developer workflow optimization, TDD for AI-generated implementations.
+ - **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.