
Unit Testing
- 254 installs
- 55 repo stars
- Updated June 10, 2026
- petrkindlmann/qa-skills
Helps with testing & qa tasks.
About
unit-testing is a Claude Code skill for testing & qa. It helps solo builders move faster with AI-assisted development.
- unit-testing
- Testing & QA
- AI-coding skill
Unit Testing by the numbers
- 254 all-time installs (skills.sh)
- +51 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #760 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/petrkindlmann/qa-skills --skill unit-testingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 254 |
|---|---|
| repo stars | ★ 55 |
| Last updated | June 10, 2026 |
| Repository | petrkindlmann/qa-skills ↗ |
What it does
Helps with testing & qa tasks.
Files
<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.
// 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.
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.
// 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:
# 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:
- 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)
npm i -D @stryker-mutator/core @stryker-mutator/jest-runner # or vitest-runner// 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:
# setup.cfg (or a [tool.mutmut] table in pyproject.toml)
[mutmut]
paths_to_mutate=src/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 changedAvoid:mutmut run --paths-to-mutate=src/,mutmut results, andmutmut show 42
— that was the mutmut <3 surface. The --paths-to-mutate flag is gone (paths move tothe[mutmut]config block) andresults/showare replaced bybrowse/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:
// 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:
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 floorTemporarily 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.*(keycoverageThreshold, singular),vitest.config.*(coverage.thresholds), orpyproject.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-filesshows no ad-hoc test paths - External boundaries (HTTP, DB, time) are mocked and internal collaborators are not —
grepfinds 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.
Unit Testing Patterns — full examples
Runnable, copy-ready examples for each framework. SKILL.md cites this file at the relevant sections; nothing here is unique guidance, it is the code behind the prose.
---
Jest
describe/it with setup/teardown and a typed mock
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 () => {
// Arrange
mockRepo.findById.mockResolvedValue({ id: "1", name: "Alice" });
// Act
const result = await service.getUser("1");
// Assert
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)
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 the real method, records calls
const spy = jest.spyOn(console, "warn").mockImplementation();
service.deprecatedMethod();
expect(spy).toHaveBeenCalledWith(expect.stringContaining("deprecated"));Timer mocking
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());
it("should debounce", () => {
const fn = jest.fn();
const debounced = debounce(fn, 300);
debounced();
expect(fn).not.toHaveBeenCalled();
jest.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});Selective faking. Faking all timers can deadlock code that awaits a real microtask (e.g. an await fetch mocked to resolve). Fake only what you need:
jest.useFakeTimers({ doNotFake: ["nextTick", "queueMicrotask"] });
// Vitest equivalent: vi.useFakeTimers({ toFake: ["setTimeout", "Date"] })Async — assert directly on the promise
await expect(fn()).resolves.toEqual({ ok: true });
await expect(fn()).rejects.toThrow(ValidationError);Guard async tests against vacuous passes (an await you forgot to write means the assertion never runs and the test goes green):
it("rejects bad input", async () => {
expect.assertions(1); // fails the test if no assertion actually ran
await expect(validate("")).rejects.toThrow();
});---
Vitest
Same API surface as Jest but Vite-native. Config:
// vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
thresholds: { branches: 80, functions: 80, lines: 80, statements: 80 },
// changed: true, // Vitest 4.1+: coverage only for files in the diff — big CI win on large repos
},
},
});Mocking with vi
vi.mock("./email-client", () => ({ sendConfirmation: vi.fn().mockResolvedValue(true) }));
const spy = vi.spyOn(repository, "save");In-source testing (useful for small utilities)
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" } (the define strips the block from the production bundle).
Monorepo workspaces
// vitest.workspace.ts
export default ["packages/*/vitest.config.ts"];Concurrency and isolation
describe.concurrent / it.concurrent run sibling tests in parallel within a file. Only use it for tests with no shared mutable state — concurrent tests that touch a shared fixture race. Vitest 5 beta removes the `sequential` option, so the way to force order is to drop .concurrent, not to flip a flag. Each concurrent test must take expect from its local context (it.concurrent("x", async ({ expect }) => …)) or assertions leak across tests.
Browser mode (Vitest 4+)
Runs component-level tests in a real browser (Playwright/WebdriverIO) instead of JSDOM. Use it when JSDOM gives false positives on layout, focus, or paint behavior; it overlaps with Cypress component testing.
// vitest.config.ts (browser mode)
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
browser: { enabled: true, provider: "playwright", name: "chromium" },
},
});---
pytest
Fixtures and conftest.py
# conftest.py
@pytest.fixture
def db():
database = Database(":memory:")
database.migrate()
yield database
database.close()
@pytest.fixture
def user_service(db):
return UserService(db)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
@pytest.mark.parametrize("input_val,expected", [
("hello world", "Hello World"), ("", ""), ("CAPS", "Caps"),
])
def test_title_case(input_val, expected):
assert title_case(input_val) == expectedMonkeypatch for mocking
def test_uses_env(monkeypatch):
monkeypatch.setenv("APP_URL", "https://test.local")
assert fetch_config()["source"] == "https://test.local"
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}Markers: @pytest.mark.slow, then run pytest -m "not slow". Use -k "test_create" for name matching.
---
Bun test / Deno test
- `bun test` — stable enough for greenfield use on the Bun stack: Jest-compatible
API, esbuild-fast, no separate runner config (package.json scripts.test).
- `deno test` — built-in for Deno projects: permission flags plus native TypeScript.
Both are reasonable defaults when your runtime is already Bun or Deno; prefer Vitest/Jest for Node projects with deeper plugin ecosystems.
---
Test doubles — the four kinds in code
// Stub — just a return value, no verification
const pricing = { getPrice: () => 9.99 };
// Spy — real behavior, calls tracked
const spy = vi.spyOn(logger, "info");
// Mock — replaced impl + interaction verified
const notifier = { send: vi.fn().mockResolvedValue(true) };
expect(notifier.send).toHaveBeenCalledWith(expect.objectContaining({ type: "done" }));
// Fake — working substitute (in-memory implementation)
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 }); }
}---
Snapshot testing — file vs inline, property matchers
// File snapshot — stored in __snapshots__/*.snap
expect(tree).toMatchSnapshot();
// Inline snapshot — stored in the test file, auto-updated on first run
expect(tree).toMatchInlineSnapshot(`<header><h1>Dashboard</h1></header>`);
// Property matchers for dynamic values — keeps the snapshot stable
expect(user).toMatchSnapshot({ id: expect.any(String), createdAt: expect.any(Date) });Prefer inline for small output (<20 lines). Always run CI with --ci so an unknown snapshot fails the run instead of being silently written and committed.