diff options
Diffstat (limited to 'languages')
| -rw-r--r-- | languages/elisp/CLAUDE.md | 68 | ||||
| -rwxr-xr-x | languages/elisp/claude/hooks/validate-el.sh | 102 | ||||
| -rw-r--r-- | languages/elisp/claude/rules/elisp-testing.md | 107 | ||||
| -rw-r--r-- | languages/elisp/claude/rules/elisp.md | 75 | ||||
| -rw-r--r-- | languages/elisp/claude/settings.json | 74 | ||||
| -rwxr-xr-x | languages/elisp/githooks/pre-commit | 50 | ||||
| -rw-r--r-- | languages/elisp/gitignore-add.txt | 7 | ||||
| -rw-r--r-- | languages/python/claude/rules/python-testing.md | 117 | ||||
| -rw-r--r-- | languages/typescript/claude/rules/typescript-testing.md | 214 |
9 files changed, 814 insertions, 0 deletions
diff --git a/languages/elisp/CLAUDE.md b/languages/elisp/CLAUDE.md new file mode 100644 index 0000000..2562695 --- /dev/null +++ b/languages/elisp/CLAUDE.md @@ -0,0 +1,68 @@ +# CLAUDE.md + +## Project + +Elisp project. Customize this section with your own description, layout, and conventions. + +**Typical layout:** +- `init.el`, `early-init.el` — entry points (Emacs config projects) +- `modules/*.el` — feature modules +- `tests/test-*.el` — ERT unit tests +- `tests/testutil-*.el` — shared test fixtures and mocks + +## Build & Test Commands + +If the project has a Makefile, document targets here. Common pattern: + +```bash +make test # All tests +make test-file FILE=tests/test-foo.el # One file +make test-name TEST=pattern # Match test names +make validate-parens # Balanced parens in modules +make validate-modules # Load all modules to verify they compile +make compile # Byte-compile (writes .elc) +make lint # checkdoc + package-lint + elisp-lint +``` + +Alternative build tools: `eldev`, `cask`, or direct `emacs --batch` invocations. + +## Language Rules + +See rule files in `.claude/rules/`: +- `elisp.md` — code style and patterns +- `elisp-testing.md` — ERT conventions +- `verification.md` — verify-before-claim-done discipline + +## Git Workflow + +Commit conventions: see `.claude/rules/commits.md` (author identity, +no AI attribution, message format). + +Pre-commit hook in `githooks/` scans for secrets and runs `check-parens` on +staged `.el` files. Activate on fresh clone with `git config core.hooksPath githooks`. + +## Problem-Solving Approach + +Investigate before fixing. When diagnosing a bug: +1. Read the relevant module and trace what actually happens +2. Identify the root cause, not a surface symptom +3. Write a failing test that captures the correct behavior +4. Fix, then re-run tests + +## Testing Discipline + +TDD is the default: write a failing test before any implementation. If you can't write the test, you don't yet understand the change. Details in `.claude/rules/elisp-testing.md`. + +## Editing Discipline + +A PostToolUse hook runs `check-parens` + `byte-compile-file` on every `.el` file after Edit/Write/MultiEdit. Byte-compile warnings (free variables, wrong argument counts) are signal — read them. + +Prefer Write over cumulative Edits for nontrivial new code. Small functions (under 15 lines) are near-impossible to get wrong; deeply nested code is where paren errors hide. + +## What Not to Do + +- Don't add features beyond what was asked +- Don't refactor surrounding code when fixing a bug +- Don't add comments to code you didn't change +- Don't create abstractions for one-time operations +- Don't commit `.env` files, credentials, or API keys — pre-commit hook catches common patterns but isn't a substitute for care diff --git a/languages/elisp/claude/hooks/validate-el.sh b/languages/elisp/claude/hooks/validate-el.sh new file mode 100755 index 0000000..803badf --- /dev/null +++ b/languages/elisp/claude/hooks/validate-el.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Validate and test .el files after Edit/Write/MultiEdit. +# PostToolUse hook: receives tool-call JSON on stdin. +# +# On success: exit 0 silent. +# On failure: emit JSON with hookSpecificOutput.additionalContext so Claude +# sees a structured error in its context, THEN exit 2 to block the tool +# pipeline. stderr still echoes the error for terminal visibility. +# +# Phase 1: check-parens + byte-compile +# Phase 2: for non-test .el files, run matching tests/test-<stem>*.el + +set -u + +# Emit a JSON failure payload and exit 2. Arguments: +# $1 — short failure type (e.g. "PAREN CHECK FAILED") +# $2 — file path +# $3 — emacs output (error body) +fail_json() { + local ctx + ctx="$(printf '%s: %s\n\n%s\n\nFix before proceeding.' "$1" "$2" "$3" \ + | jq -Rs .)" + cat <<EOF +{"hookSpecificOutput": {"hookEventName": "PostToolUse", "additionalContext": $ctx}} +EOF + printf '%s: %s\n%s\n' "$1" "$2" "$3" >&2 + exit 2 +} + +# Portable project root: prefer Claude Code's env var, fall back to deriving +# from this script's location ($project/.claude/hooks/validate-el.sh). +PROJECT_ROOT="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}" + +f="$(jq -r '.tool_input.file_path // .tool_response.filePath // empty')" +[ -z "$f" ] && exit 0 +[ "${f##*.}" = "el" ] || exit 0 + +MAX_AUTO_TEST_FILES=20 # skip if more matches than this (large test suites) + +# --- Phase 1: syntax + byte-compile --- +case "$f" in + */init.el|*/early-init.el) + # Byte-compile here would load the full package graph. Parens only. + if ! output="$(emacs --batch --no-site-file --no-site-lisp "$f" \ + --eval '(check-parens)' 2>&1)"; then + fail_json "PAREN CHECK FAILED" "$f" "$output" + fi + ;; + *.el) + if ! output="$(emacs --batch --no-site-file --no-site-lisp \ + -L "$PROJECT_ROOT" \ + -L "$PROJECT_ROOT/modules" \ + -L "$PROJECT_ROOT/tests" \ + --eval '(package-initialize)' \ + "$f" \ + --eval '(check-parens)' \ + --eval "(or (byte-compile-file \"$f\") (kill-emacs 1))" 2>&1)"; then + fail_json "VALIDATION FAILED" "$f" "$output" + fi + ;; +esac + +# --- Phase 2: test runner --- +# Determine which tests (if any) apply to this edit. Works for projects with +# source at root, in modules/, or elsewhere — stem-based test lookup is the +# common pattern. +tests=() +case "$f" in + */init.el|*/early-init.el) + : # Phase 1 handled it; skip test runner + ;; + "$PROJECT_ROOT/tests/testutil-"*.el) + stem="$(basename "${f%.el}")" + stem="${stem#testutil-}" + mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort) + ;; + "$PROJECT_ROOT/tests/test-"*.el) + tests=("$f") + ;; + *.el) + # Any other .el under the project — find matching tests by stem + stem="$(basename "${f%.el}")" + mapfile -t tests < <(find "$PROJECT_ROOT/tests" -maxdepth 1 -name "test-${stem}*.el" 2>/dev/null | sort) + ;; +esac + +count="${#tests[@]}" +if [ "$count" -ge 1 ] && [ "$count" -le "$MAX_AUTO_TEST_FILES" ]; then + load_args=() + for t in "${tests[@]}"; do load_args+=("-l" "$t"); done + if ! output="$(emacs --batch --no-site-file --no-site-lisp \ + -L "$PROJECT_ROOT" \ + -L "$PROJECT_ROOT/modules" \ + -L "$PROJECT_ROOT/tests" \ + --eval '(package-initialize)' \ + -l ert "${load_args[@]}" \ + --eval "(ert-run-tests-batch-and-exit '(not (tag :slow)))" 2>&1)"; then + fail_json "TESTS FAILED ($count test file(s))" "$f" "$output" + fi +fi + +exit 0 diff --git a/languages/elisp/claude/rules/elisp-testing.md b/languages/elisp/claude/rules/elisp-testing.md new file mode 100644 index 0000000..b5def78 --- /dev/null +++ b/languages/elisp/claude/rules/elisp-testing.md @@ -0,0 +1,107 @@ +# Elisp Testing Rules + +Applies to: `**/tests/*.el` + +Implements the core principles from `testing.md`. All rules there apply here — +this file covers Elisp-specific patterns. + +## Framework: ERT + +Use `ert-deftest` for all tests. One test = one scenario. + +## File Layout + +- `tests/test-<module>.el` — tests for `<module>.el` +- `tests/test-<module>--<helper>.el` — tests for a specific private helper (matches `<module>--<helper>` function naming) +- `tests/testutil-<module>.el` — fixtures and mocks scoped to one module +- `tests/testutil-*.el` — cross-module helpers (shared fixtures, generic mocks, filesystem helpers); name them for what they help with + +Tests must `(require 'module-name)` before the testutil file that stubs its internals, unless documented otherwise. Order matters — a testutil that defines a stub can be shadowed by a later `require` of the real module. + +## Test Naming + +```elisp +(ert-deftest test-<module>-<function>-<scenario> () + "Normal/Boundary/Error: brief description." + ...) +``` + +Put the category (Normal, Boundary, Error) in the docstring so the category is grep-able. + +## Required Coverage + +Every non-trivial function needs at least: +- One **Normal** case (happy path) +- One **Boundary** case (empty, nil, min, max, unicode, long string) +- One **Error** case (invalid input, missing resource, failure mode) + +Missing a category is a test gap. If three cases look near-identical, parametrize with a loop or `dolist` rather than copy-pasting. + +## TDD Workflow + +Write the failing test first. A failing test proves you understand the change. Assume the bug is in production code until the test proves otherwise — never fix the test before proving the test is wrong. + +For untested code, write a **characterization test** that captures current behavior before you change anything. It becomes the safety net for the refactor. + +## Interactive vs Internal — Split for Testability + +When a function mixes business logic with user interaction, split it: + +- **Internal** (`cj/--foo`) — pure logic. All parameters explicit. No prompts, + no UI. Deterministic and trivially testable. +- **Interactive wrapper** (`cj/foo`) — thin layer that reads user input and + delegates to the internal. + +```elisp +(defun cj/--move-buffer-and-file (dir &optional ok-if-exists) + "Move the current buffer's file into DIR. Overwrite if OK-IF-EXISTS." + ...) + +(defun cj/move-buffer-and-file () + "Interactive wrapper: prompt for DIR, delegate." + (interactive) + (let ((dir (read-directory-name "Move to: "))) + (cj/--move-buffer-and-file dir))) +``` + +Test the internal directly with parameter values — no `cl-letf` on +`read-directory-name`, `yes-or-no-p`, etc. The wrapper gets a smoke test or +nothing — Emacs already tests its own prompts. The internal also becomes +reusable by other Elisp code without triggering UI. + +## Mocking + +Mock at boundaries: +- Shell: `cl-letf` on `shell-command`, `shell-command-to-string`, `call-process` +- File I/O when tests shouldn't touch disk +- Network: URL retrievers, HTTP clients +- Time: `cl-letf` on `current-time`, `format-time-string` + +Never mock: +- The code under test +- Core Emacs primitives (buffer ops, string ops, lists) +- Your own domain logic — restructure it to be testable instead + +## Idioms + +- `cl-letf` for scoped overrides (self-cleaning) +- `with-temp-buffer` for buffer manipulation tests +- `make-temp-file` with `.el` suffix for on-disk fixtures +- Tests must run in any order; no shared mutable state + +## Running Tests + +```bash +make test # All +make test-file FILE=tests/test-foo.el # One file +make test-name TEST=pattern # Match by test name pattern +``` + +A PostToolUse hook runs matching tests automatically after edits to a module, when the match count is small enough to be fast. + +## Anti-Patterns + +- Hardcoded timestamps — generate relative to `current-time` or mock +- Testing implementation details (private storage structure) instead of behavior +- Mocking the thing you're testing +- Skipping a failing test without an issue to track it diff --git a/languages/elisp/claude/rules/elisp.md b/languages/elisp/claude/rules/elisp.md new file mode 100644 index 0000000..e641058 --- /dev/null +++ b/languages/elisp/claude/rules/elisp.md @@ -0,0 +1,75 @@ +# Elisp / Emacs Rules + +Applies to: `**/*.el` + +## Style + +- 2-space indent, no tabs +- Hyphen-case for identifiers: `cj/do-thing`, not `cj/doThing` +- Naming prefixes: + - `cj/name` — user-facing functions and commands (bound to keys, called from init) + - `cj/--name` — private helpers (double-dash signals "internal") + - `<module>/name` — module-scoped where appropriate (e.g., `calendar-sync/parse-ics`) +- File header: `;;; foo-config.el --- brief description -*- lexical-binding: t -*-` +- `(provide 'foo-config)` at the bottom of every module +- `lexical-binding: t` is mandatory — no file without it + +## Function Design + +- Keep functions under 15 lines where possible +- One responsibility per function +- Extract helpers instead of nesting deeply — 5+ levels of nesting is a refactor signal +- Prefer named helpers over lambdas for anything nontrivial +- No premature abstraction — three similar lines beats a clever macro + +Small functions are the single strongest defense against paren errors. Deeply nested code is where AI and humans both fail. + +## Requires and Loading + +- Every `(require 'foo)` must correspond to a loadable file on the load-path +- Byte-compile warnings about free variables usually indicate a missing `require` or a typo in a symbol name — read them +- Use `use-package` for external (MELPA/ELPA) packages +- Use plain `(require 'foo-config)` for internal modules +- For optional features, `(when (require 'foo nil t) ...)` degrades gracefully if absent + +## Lexical-Binding Traps + +- `(boundp 'x)` where `x` is a lexical variable always returns nil. Bind with `defvar` at top level if you need `boundp` to work, or use the value directly. +- `setq` on an undeclared free variable is a warning — use `let` for locals or `defvar` for module-level state +- Closures capture by reference. Avoid capturing mutating loop variables in nested defuns. + +## Regex Gotchas + +- `\s` is NOT whitespace in Emacs regex. Use `[ \t]` or `\\s-` (syntax class). +- `^` in `string-match` matches after `\n` OR at position 0 — use `(= (match-beginning 0) start)` for positional checks when that matters. +- `replace-regexp-in-string` interprets backslashes in the replacement. Pass `t t` (FIXEDCASE LITERAL) when the replacement contains literal backslashes. + +## Keybindings + +- `keymap-global-set` for global; `keymap-set KEYMAP ...` for mode-local +- Group module-specific bindings inside the module's file +- Autoload cookies (`;;;###autoload`) don't activate through plain `(require ...)` — use the form directly, not an autoloaded wrapper + +## Module Template + +```elisp +;;; foo-config.el --- Foo feature configuration -*- lexical-binding: t -*- + +;;; Commentary: +;; One-line description. + +;;; Code: + +;; ... code ... + +(provide 'foo-config) +;;; foo-config.el ends here +``` + +Then `(require 'foo-config)` in `init.el` (or a config aggregator). + +## Editing Workflow + +- A PostToolUse hook runs `check-parens` and `byte-compile-file` on every `.el` save +- If it blocks, read the error — don't retry blindly +- Prefer Write over repeated Edits for nontrivial new code; incremental edits accumulate subtle paren mismatches diff --git a/languages/elisp/claude/settings.json b/languages/elisp/claude/settings.json new file mode 100644 index 0000000..9ab9f12 --- /dev/null +++ b/languages/elisp/claude/settings.json @@ -0,0 +1,74 @@ +{ + "attribution": { + "commit": "", + "pr": "" + }, + "permissions": { + "allow": [ + "Bash(make)", + "Bash(make help)", + "Bash(make targets)", + "Bash(make test)", + "Bash(make test *)", + "Bash(make test-all)", + "Bash(make test-unit)", + "Bash(make test-integration)", + "Bash(make test-file *)", + "Bash(make test-name *)", + "Bash(make validate-parens)", + "Bash(make validate-modules)", + "Bash(make compile)", + "Bash(make lint)", + "Bash(make profile)", + "Bash(emacs --batch *)", + "Bash(emacs -Q --batch *)", + "Bash(git status)", + "Bash(git status *)", + "Bash(git diff)", + "Bash(git diff *)", + "Bash(git log)", + "Bash(git log *)", + "Bash(git show)", + "Bash(git show *)", + "Bash(git blame *)", + "Bash(git branch)", + "Bash(git branch -v)", + "Bash(git branch -a)", + "Bash(git branch --list *)", + "Bash(git remote)", + "Bash(git remote -v)", + "Bash(git remote show *)", + "Bash(git ls-files *)", + "Bash(git rev-parse *)", + "Bash(git cat-file *)", + "Bash(git stash list)", + "Bash(git stash show *)", + "Bash(jq *)", + "Bash(date)", + "Bash(date *)", + "Bash(which *)", + "Bash(file *)", + "Bash(ls)", + "Bash(ls *)", + "Bash(wc *)", + "Bash(du *)", + "Bash(readlink *)", + "Bash(realpath *)", + "Bash(basename *)", + "Bash(dirname *)" + ] + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/validate-el.sh" + } + ] + } + ] + } +} diff --git a/languages/elisp/githooks/pre-commit b/languages/elisp/githooks/pre-commit new file mode 100755 index 0000000..909cde2 --- /dev/null +++ b/languages/elisp/githooks/pre-commit @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Pre-commit hook: secret scan + paren validation on staged .el files. +# Use `git commit --no-verify` to bypass for confirmed false positives. + +set -u + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +# --- 1. Secret scan --- +# Patterns for common credentials. Scans only added lines in the staged diff. +SECRET_PATTERNS='(AKIA[0-9A-Z]{16}|sk-[a-zA-Z0-9_-]{20,}|-----BEGIN (RSA|DSA|EC|OPENSSH|PGP)( PRIVATE)?( KEY| KEY BLOCK)?-----|(api[_-]?key|api[_-]?secret|auth[_-]?token|secret[_-]?key|bearer[_-]?token|access[_-]?token|password)[[:space:]]*[:=][[:space:]]*["'"'"'][^"'"'"']{16,}["'"'"'])' + +secret_hits="$(git diff --cached -U0 --diff-filter=AM \ + | grep '^+' | grep -v '^+++' \ + | grep -iEn "$SECRET_PATTERNS" || true)" + +if [ -n "$secret_hits" ]; then + echo "pre-commit: potential secret in staged changes:" >&2 + echo "$secret_hits" >&2 + echo "" >&2 + echo "Review the lines above. If this is a false positive (test fixture, documentation)," >&2 + echo "bypass with: git commit --no-verify" >&2 + exit 1 +fi + +# --- 2. Paren check on staged .el files --- +staged_el="$(git diff --cached --name-only --diff-filter=AM | grep '\.el$' || true)" + +if [ -n "$staged_el" ]; then + paren_fail="" + while IFS= read -r f; do + [ -z "$f" ] && continue + [ -f "$f" ] || continue + if ! out="$(emacs --batch --no-site-file --no-site-lisp "$f" \ + --eval '(check-parens)' 2>&1)"; then + paren_fail="${paren_fail}${f}: +${out} + +" + fi + done <<< "$staged_el" + + if [ -n "$paren_fail" ]; then + printf 'pre-commit: paren check failed:\n\n%s' "$paren_fail" >&2 + exit 1 + fi +fi + +exit 0 diff --git a/languages/elisp/gitignore-add.txt b/languages/elisp/gitignore-add.txt new file mode 100644 index 0000000..72d8290 --- /dev/null +++ b/languages/elisp/gitignore-add.txt @@ -0,0 +1,7 @@ +# Claude Code — personal overrides (not part of the ruleset) +/.claude/settings.local.json +/.claude/.cache/ + +# Elisp byte-compile artifacts (generated by the hook) +*.elc +*.eln diff --git a/languages/python/claude/rules/python-testing.md b/languages/python/claude/rules/python-testing.md new file mode 100644 index 0000000..d2342c1 --- /dev/null +++ b/languages/python/claude/rules/python-testing.md @@ -0,0 +1,117 @@ +# Python Testing Rules + +Applies to: `**/*.py` + +Implements the core principles from `testing.md`. All rules there apply here — +this file covers Python-specific patterns. + +## Framework: pytest (NEVER unittest) + +Use `pytest` for all Python tests. Do not use `unittest.TestCase` unless +integrating with legacy code that requires it. + +## Test Structure + +Group tests in classes that mirror the source module: + +```python +class TestCartService: + """Tests for CartService.""" + + @pytest.fixture + def cart(self): + return Cart(user_id=42) + + def test_add_item_normal(self, cart): + """Normal: adding an in-stock item increases quantity.""" + cart.add("SKU-1", quantity=2) + assert cart.item_count("SKU-1") == 2 + + def test_add_item_boundary_zero_quantity(self, cart): + """Boundary: quantity 0 is a no-op, not an error.""" + cart.add("SKU-1", quantity=0) + assert cart.item_count("SKU-1") == 0 + + def test_add_item_error_negative(self, cart): + """Error: negative quantity raises ValueError.""" + with pytest.raises(ValueError, match="quantity must be non-negative"): + cart.add("SKU-1", quantity=-1) +``` + +## Fixtures Over Factories + +- Use `pytest` fixtures for test data setup +- Use `@pytest.fixture(autouse=True)` sparingly — prefer explicit injection +- Avoid `factory_boy` unless object graphs are genuinely complex +- Django: prefer pytest fixtures over `setUpTestData` unless you have a + performance reason + +## Parametrize for Category Coverage + +Use `@pytest.mark.parametrize` to cover normal, boundary, and error cases +concisely instead of hand-writing near-duplicate tests: + +```python +@pytest.mark.parametrize("quantity,valid", [ + (1, True), # Normal + (100, True), # Normal: bulk + (0, True), # Boundary: zero is a no-op + (-1, False), # Error: negative +]) +def test_add_item_quantity_validation(cart, quantity, valid): + if valid: + cart.add("SKU-1", quantity=quantity) + else: + with pytest.raises(ValueError): + cart.add("SKU-1", quantity=quantity) +``` + +### Pairwise / Combinatorial for Parameter-Heavy Functions + +When `@pytest.mark.parametrize` would require listing dozens of combinations +(feature flags × permissions × shipping × payment × etc.), switch to +combinatorial coverage via `/pairwise-tests`. The skill generates a minimal +matrix covering every 2-way parameter interaction — typically 80-99% fewer +cases than exhaustive, catching most combinatorial bugs. + +Workflow: invoke `/pairwise-tests` → get a PICT model + generated test matrix +→ paste the matrix into a pytest parametrize block, or use the helper to +emit directly. The `pypict` package (`pip install pypict`) handles +generation in-process. + +See `testing.md` § Combinatorial Coverage for the general rule and when +to skip. + +## Mocking Guidelines + +### Mock these (external boundaries): +- External APIs (`requests`, `httpx`, `boto3` clients) +- Time (`freezegun` or `time-machine`) +- File uploads (Django: `SimpleUploadedFile`) +- Celery tasks (`@override_settings(CELERY_ALWAYS_EAGER=True)`) +- Email sending (Django: `django.core.mail.outbox`) + +### Never mock these (internal domain): +- ORM queries (SQLAlchemy, Django ORM) +- Model methods and properties +- Form and serializer validation +- Middleware +- Your own service functions + +## Async Testing + +Use `anyio` for async tests (not raw `asyncio`): + +```python +@pytest.mark.anyio +async def test_process_order_async(): + result = await process_order_async(sample_order) + assert result.status == "processed" +``` + +## Database Testing (Django) + +- Mark database tests with `@pytest.mark.django_db` +- Use transactions for isolation (pytest-django default) +- Prefer in-memory SQLite for speed in unit tests +- Use `select_related` / `prefetch_related` assertions to catch N+1 regressions diff --git a/languages/typescript/claude/rules/typescript-testing.md b/languages/typescript/claude/rules/typescript-testing.md new file mode 100644 index 0000000..bd6933f --- /dev/null +++ b/languages/typescript/claude/rules/typescript-testing.md @@ -0,0 +1,214 @@ +# TypeScript Testing Rules + +Applies to: `**/*.{ts,tsx}` + +Implements the core principles from `testing.md`. All rules there apply here — +this file covers TypeScript-specific patterns. + +## Framework: Vitest (canonical) + +Use Vitest for new TypeScript code. It's native ESM, native TS, fastest watch +mode, and shares its config with Vite when the project already uses it. + +For legacy code, the same principles apply with different idioms: + +- **Jest** — same `describe`/`it`/`expect` API; substitute `jest.mock` for + `vi.mock`. Most patterns below port directly. +- **Mocha + Chai** (Node backends) — `describe`/`it` are the same; assertions + use `expect(x).to.equal(y)` instead of `expect(x).toBe(y)`. Sinon for spies. +- **Angular + Karma** (`ng test`) — different planet. Follow Angular's testing + guide; the Normal/Boundary/Error category discipline still applies. + +Don't mix frameworks in one package. Don't introduce a second framework to +"try it out" — pick the one that fits and commit. + +## Test Structure + +Group tests in `describe` blocks that mirror the source module. Use +`beforeEach` for setup that every test in the block needs: + +```ts +import { beforeEach, describe, expect, it } from "vitest"; +import { Cart } from "./cart"; + +describe("Cart", () => { + let cart: Cart; + + beforeEach(() => { + cart = new Cart({ userId: 42 }); + }); + + it("normal: adding an in-stock item increases quantity", () => { + cart.add("SKU-1", 2); + expect(cart.itemCount("SKU-1")).toBe(2); + }); + + it("boundary: quantity 0 is a no-op, not an error", () => { + cart.add("SKU-1", 0); + expect(cart.itemCount("SKU-1")).toBe(0); + }); + + it("error: negative quantity throws", () => { + expect(() => cart.add("SKU-1", -1)).toThrow(/quantity must be non-negative/); + }); +}); +``` + +Co-locate test files with the source under test (`cart.ts` ↔ `cart.test.ts`) +unless the project layout dictates a separate `tests/` tree. Match the project's +existing convention. + +## Fixtures via Factory Functions + +TypeScript has no pytest-style fixture system. Use plain factory functions for +test data — they're typed, refactor-safe, and explicit: + +```ts +const makeUser = (overrides: Partial<User> = {}): User => ({ + id: "u-1", + email: "alice@example.com", + role: "member", + ...overrides, +}); + +it("admin can delete posts", () => { + const admin = makeUser({ role: "admin" }); + expect(canDeletePosts(admin)).toBe(true); +}); +``` + +Avoid `faker`/`@faker-js/faker` unless the project genuinely needs random data. +Random fixtures hide off-by-one bugs and make test failures non-reproducible. +Seed deterministically when faker is unavoidable. + +## Parametrize for Category Coverage + +Use `it.each` to cover normal, boundary, and error cases concisely instead of +hand-writing near-duplicate tests: + +```ts +it.each<[number, boolean]>([ + [1, true], // Normal + [100, true], // Normal: bulk + [0, true], // Boundary: zero is a no-op + [-1, false], // Error: negative +])("add(SKU-1, %i) — valid=%s", (quantity, valid) => { + if (valid) { + cart.add("SKU-1", quantity); + } else { + expect(() => cart.add("SKU-1", quantity)).toThrow(); + } +}); +``` + +### Pairwise / Combinatorial for Parameter-Heavy Functions + +When `it.each` would require listing dozens of combinations (feature flags × +permissions × shipping × payment × etc.), switch to combinatorial coverage via +`/pairwise-tests`. The skill generates a minimal matrix covering every 2-way +parameter interaction — typically 80-99% fewer cases than exhaustive, +catching most combinatorial bugs. + +Workflow: invoke `/pairwise-tests` → get a PICT model + generated test matrix +→ paste the matrix into an `it.each` block. See `testing.md` § Combinatorial +Coverage for the general rule and when to skip. + +## Mocking Guidelines + +### Mock these (external boundaries): +- HTTP clients (`fetch`, `axios`, `ky`) — prefer MSW (see below) over + client-level mocks where possible. +- File / filesystem APIs (`fs/promises`) +- Time (`vi.useFakeTimers()` + `vi.setSystemTime(...)`) +- Browser globals not covered by jsdom (`navigator.geolocation`, + `IntersectionObserver`, `ResizeObserver`) +- Third-party SDKs (Stripe client, AWS SDK clients) + +### Never mock these (internal domain): +- Your own service, hook, or utility functions +- Type-narrowing helpers (`isFoo`, `assertBar`) — those are the work +- Validation libraries (`zod`, `valibot`) — they're framework, not boundary +- React's render lifecycle, hooks, or context — use RTL to exercise the real thing + +If a unit test needs heavy internal mocking, the production code needs +restructuring (see `testing.md` § *If Tests Are Hard to Write, Refactor the +Code*). + +## Async Testing + +Use native `async`/`await`. Don't wrap in promise chains, don't mix `done` +callbacks: + +```ts +it("processOrder resolves with status 'processed'", async () => { + const result = await processOrder(sampleOrder); + expect(result.status).toBe("processed"); +}); +``` + +For React component tests, use RTL's `findBy*` (auto-waits) over +`waitFor(() => getBy*)` — `findBy*` is purpose-built for the "appears +eventually" case. + +## React Testing Library + +When testing React components: + +- **Query priority**: `getByRole` > `getByLabelText` > `getByPlaceholderText` + > `getByText`. Reach for `getByTestId` only when the others genuinely don't + fit. Test like the user — don't query CSS classes or DOM structure. +- **`userEvent` over `fireEvent`**. `userEvent` simulates real interaction + (focus, keyboard, async). `fireEvent` is a low-level escape hatch. +- **One assertion per behavioral concern**. A test that asserts "button + renders" + "button submits the form" + "form clears on submit" is three + tests. +- **Don't snapshot DOM**. Snapshots rot fast and reviewers rubber-stamp them. + Assert specific attributes and text instead. + +## Network Mocking: MSW + +For request-level mocking in component and integration tests, use MSW (Mock +Service Worker). It intercepts at the network layer, so the app code is +exercised end-to-end up to the boundary: + +```ts +import { http, HttpResponse } from "msw"; +import { setupServer } from "msw/node"; + +const server = setupServer( + http.get("/api/orders/:id", ({ params }) => + HttpResponse.json({ id: params.id, status: "shipped" }) + ) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +``` + +Prefer MSW over mocking `fetch` or `axios` directly — request mocks survive +client-library swaps; `vi.mock("axios")` doesn't. + +## TypeScript-Specific Discipline + +- **No `any` in tests.** Tests are documentation; `any` lies about the shape. + Use `unknown` and narrow, or define the precise type. +- **Prefer `satisfies` over type assertions.** `as` says "trust me"; `satisfies` + says "verify this conforms" without widening the inferred type. +- **Don't disable strict checks for tests.** Same `tsconfig` as production. + If a test needs `// @ts-expect-error`, leave a comment explaining the + invariant being verified. +- **Assertion functions for invariants.** When narrowing via runtime check is + expressive, write `assertIsFoo(value)` (returns `asserts value is Foo`) + instead of casting in every test. + +## Anti-Patterns (TypeScript-Specific) + +- Casting test data with `as Whatever` to silence a type error — fix the + factory or the type. +- Using `jest.mock` patterns in a Vitest project (or vice versa) — pick one. +- Snapshot-testing JSX trees — brittle, low-signal. +- Testing `useState` / `useReducer` directly via `renderHook` when the same + behavior is reachable through the component's UI — render the component. +- `expect(x).toBeTruthy()` when you mean `expect(x).toBe(true)` — they are + different invariants and the looser one masks bugs. |
