@mintwork/honest-tests 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.claude-plugin/marketplace.json +22 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/CHANGELOG.md +56 -0
  4. package/LICENSE +21 -0
  5. package/README.md +280 -2
  6. package/commands/audit.md +17 -0
  7. package/commands/mutate.md +10 -0
  8. package/dist/adapters/index.d.ts +3 -0
  9. package/dist/adapters/index.js +8 -0
  10. package/dist/adapters/js/ast.d.ts +24 -0
  11. package/dist/adapters/js/ast.js +199 -0
  12. package/dist/adapters/js/index.d.ts +25 -0
  13. package/dist/adapters/js/index.js +122 -0
  14. package/dist/adapters/js/mutants.d.ts +8 -0
  15. package/dist/adapters/js/mutants.js +190 -0
  16. package/dist/adapters/js/parse-tests.d.ts +5 -0
  17. package/dist/adapters/js/parse-tests.js +1101 -0
  18. package/dist/adapters/python/index.d.ts +17 -0
  19. package/dist/adapters/python/index.js +26 -0
  20. package/dist/cli.d.ts +2 -0
  21. package/dist/cli.js +21 -0
  22. package/dist/core/engine.d.ts +44 -0
  23. package/dist/core/engine.js +205 -0
  24. package/dist/core/types.d.ts +195 -0
  25. package/dist/core/types.js +7 -0
  26. package/dist/index.d.ts +6 -0
  27. package/dist/index.js +5 -0
  28. package/dist/mutate/runner.d.ts +59 -0
  29. package/dist/mutate/runner.js +379 -0
  30. package/dist/report/colors.d.ts +11 -0
  31. package/dist/report/colors.js +13 -0
  32. package/dist/report/human.d.ts +6 -0
  33. package/dist/report/human.js +79 -0
  34. package/dist/report/json.d.ts +4 -0
  35. package/dist/report/json.js +8 -0
  36. package/dist/rules/asserts-on-mock.d.ts +2 -0
  37. package/dist/rules/asserts-on-mock.js +53 -0
  38. package/dist/rules/asserts-on-own-literal.d.ts +2 -0
  39. package/dist/rules/asserts-on-own-literal.js +36 -0
  40. package/dist/rules/catch-only-assertion.d.ts +2 -0
  41. package/dist/rules/catch-only-assertion.js +22 -0
  42. package/dist/rules/conditional-assertion.d.ts +2 -0
  43. package/dist/rules/conditional-assertion.js +30 -0
  44. package/dist/rules/excessive-mocking.d.ts +3 -0
  45. package/dist/rules/excessive-mocking.js +22 -0
  46. package/dist/rules/focused-test.d.ts +2 -0
  47. package/dist/rules/focused-test.js +15 -0
  48. package/dist/rules/helpers.d.ts +5 -0
  49. package/dist/rules/helpers.js +18 -0
  50. package/dist/rules/index.d.ts +3 -0
  51. package/dist/rules/index.js +31 -0
  52. package/dist/rules/mocked-unit-under-test.d.ts +2 -0
  53. package/dist/rules/mocked-unit-under-test.js +32 -0
  54. package/dist/rules/never-failing-assertion.d.ts +2 -0
  55. package/dist/rules/never-failing-assertion.js +43 -0
  56. package/dist/rules/no-assertions.d.ts +2 -0
  57. package/dist/rules/no-assertions.js +16 -0
  58. package/dist/rules/skipped-test.d.ts +2 -0
  59. package/dist/rules/skipped-test.js +15 -0
  60. package/dist/rules/snapshot-only.d.ts +2 -0
  61. package/dist/rules/snapshot-only.js +18 -0
  62. package/dist/rules/swallowed-assertion.d.ts +2 -0
  63. package/dist/rules/swallowed-assertion.js +18 -0
  64. package/dist/rules/tautological-assertion.d.ts +2 -0
  65. package/dist/rules/tautological-assertion.js +42 -0
  66. package/dist/run.d.ts +12 -0
  67. package/dist/run.js +204 -0
  68. package/integrations/codex/AGENTS.md +20 -0
  69. package/integrations/cursor/honest-tests.mdc +24 -0
  70. package/package.json +73 -4
  71. package/skills/honest-tests/SKILL.md +141 -0
  72. package/skills/honest-tests/references/audit-procedure.md +128 -0
  73. package/skills/honest-tests/references/mutation-testing.md +68 -0
  74. package/skills/honest-tests/references/test-smells.md +268 -0
  75. package/skills/honest-tests/references/writing-honest-tests.md +96 -0
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@mintwork/honest-tests",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Stop AI coding agents from writing fake tests. An agent skill + CLI that detects tests that can't fail, and proves it with mutation testing.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/mintwork/honest-tests.git"
8
+ },
9
+ "homepage": "https://github.com/mintwork/honest-tests#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/mintwork/honest-tests/issues"
12
+ },
13
+ "type": "module",
14
+ "bin": {
15
+ "honest-tests": "dist/cli.js"
16
+ },
17
+ "main": "dist/index.js",
18
+ "types": "dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "import": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "skills",
28
+ "commands",
29
+ "integrations",
30
+ ".claude-plugin",
31
+ "README.md",
32
+ "CHANGELOG.md",
33
+ "LICENSE"
34
+ ],
35
+ "engines": {
36
+ "node": ">=22"
37
+ },
38
+ "scripts": {
39
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
40
+ "typecheck": "tsc -p tsconfig.json --noEmit",
41
+ "test": "vitest run",
42
+ "dogfood": "node dist/cli.js audit test",
43
+ "demo:bad": "node dist/cli.js audit fixtures/bad-project --mutate fixtures/bad-project/src/pricing.ts",
44
+ "demo:good": "node dist/cli.js audit fixtures/good-project --mutate fixtures/good-project/src/pricing.ts",
45
+ "demo:gif": "npm run build && node scripts/demo-gif.mjs",
46
+ "prepare": "npm run build"
47
+ },
48
+ "keywords": [
49
+ "testing",
50
+ "test-smells",
51
+ "mutation-testing",
52
+ "agent-skills",
53
+ "claude-code",
54
+ "codex",
55
+ "cursor",
56
+ "ai",
57
+ "vitest",
58
+ "jest",
59
+ "lint"
60
+ ],
61
+ "license": "MIT",
62
+ "dependencies": {
63
+ "@babel/parser": "^7.29.0"
64
+ },
65
+ "devDependencies": {
66
+ "@babel/types": "^7.29.0",
67
+ "@types/node": "^22.20.5",
68
+ "playwright-core": "^1.65.1",
69
+ "typescript": "^7.0.2",
70
+ "vitest": "^5.1.0"
71
+ },
72
+ "publishConfig": {
73
+ "access": "public"
74
+ }
75
+ }
@@ -0,0 +1,141 @@
1
+ ---
2
+ name: honest-tests
3
+ description: Write and audit tests that actually verify behavior instead of tests that merely pass. Use when writing, generating, reviewing or fixing unit tests (vitest, jest; pytest planned), when asked to "add tests", "increase coverage", "audit/review the tests", or before claiming a change is tested. Detects fake tests (tautological assertions, no assertions, asserting on mocks you configured or on literals the test just built, mocking the unit under test, snapshot-only tests, try/catch that swallows failures, assertions that can never fail, leftover .only/.skip) and proves weak tests with lightweight mutation testing via the `honest-tests` CLI.
4
+ license: MIT
5
+ metadata:
6
+ version: "0.1.1"
7
+ cli: "npx @mintwork/honest-tests"
8
+ ---
9
+
10
+ # Honest Tests
11
+
12
+ A test is honest only if **it fails when the behavior it names is broken**. Everything in this skill
13
+ serves that one rule. Green is not the goal; *a test that can go red for the right reason* is.
14
+
15
+ AI-written test suites fail this rule in predictable ways: they assert that a value equals itself,
16
+ assert on the return value of a mock they just configured, mock the very module under test, wrap
17
+ assertions in `try/catch`, or never assert at all. They pass forever and protect nothing.
18
+
19
+ Use this skill in two modes:
20
+
21
+ - **Writing tests** — follow the rules below, then self-audit before you report done.
22
+ - **Auditing tests** — run the phased procedure: detect → verify → report (→ fix, if asked).
23
+
24
+ ## Hard rules (never break these)
25
+
26
+ 1. **Never weaken an assertion to make a test pass.** If a test fails, first assume the *code* may be
27
+ wrong. Changing `toBe(70)` to `toBe(80)` because the output was 80 is only allowed after you have
28
+ independently confirmed 80 is correct (spec, hand calculation, domain rule).
29
+ 2. **The expected value must come from somewhere other than the code under test.** Hand-computed
30
+ literals, fixtures, or a spec — never `expect(fn(x)).toBe(fn(x))` and never a value copied from a
31
+ run of the code you are testing without checking it.
32
+ 3. **Never mock the unit under test.** Mock only its boundaries (network, DB, clock, randomness,
33
+ filesystem, other services). If you must mock half the module to test it, say so — that is a design
34
+ smell to report, not hide.
35
+ 4. **Assert on what the unit produces**, not on what a mock was told to return. Mocks are for
36
+ *isolating* the unit and for *interaction* checks (`toHaveBeenCalledWith`), not for producing the
37
+ value you assert on. The same goes for literals the test built itself: `const p = { service: 's3' };
38
+ expect(p.service).toBe('s3')` checks nothing. Types don't exist at runtime — check types with
39
+ `expectTypeOf` / `tsd` / `// @ts-expect-error`, and check behavior by passing the literal *through* the unit.
40
+ 5. **No assertion inside `try/catch`.** Use `expect(() => f()).toThrow(...)` /
41
+ `await expect(p).rejects.toThrow(...)`.
42
+ 6. **Every test asserts something specific.** "Does not throw", `toBeDefined()`, `toBeTruthy()` and a
43
+ bare snapshot are almost never the real contract. Assert the exact value, field, or call.
44
+ 7. **Never leave `.only` / `.skip` / `xit`** in committed code. Use `it.todo('...')` for planned tests.
45
+ 8. **Coverage is not verification.** A line executed by a test is not a line checked by a test.
46
+
47
+ Detailed patterns and examples: [references/writing-honest-tests.md](references/writing-honest-tests.md).
48
+
49
+ ## Mode 1 — Writing tests
50
+
51
+ Before writing each test, answer in one line: *"Which bug would make this test fail?"* If you can't
52
+ name one, don't write the test.
53
+
54
+ For each unit:
55
+
56
+ 1. List its behaviors: normal results, **boundaries** (0, empty, max, `>=` vs `>`), error paths, and
57
+ side effects (calls it must make).
58
+ 2. Write one focused test per behavior with a hand-derived expected value. Name the test after the
59
+ behavior (`'charges 5 below the free-shipping threshold'`), not the function (`'works'`).
60
+ 3. Mock only boundaries. Prefer real collaborators when they are cheap and deterministic.
61
+ 4. Self-audit when done (both commands are safe; neither modifies your files):
62
+
63
+ ```bash
64
+ npx @mintwork/honest-tests audit <test files or dir>
65
+ npx @mintwork/honest-tests mutate <source file you just tested>
66
+ ```
67
+
68
+ 5. Fix every `error` finding. For each **surviving mutant**, either add the missing assertion (usually
69
+ a boundary or an exact value) or explain why the mutant is equivalent. Report the final mutation
70
+ score to the user.
71
+
72
+ ## Mode 2 — Auditing tests (phased)
73
+
74
+ Run the phases in order. Do not skip Phase 2: unverified findings erode trust.
75
+
76
+ ### Phase 1 — Detect
77
+
78
+ ```bash
79
+ npx @mintwork/honest-tests audit <path> --json > honest-tests.json
80
+ ```
81
+
82
+ The JSON contains `findings[]` with `ruleId`, `severity`, `file`, `line`, `column`, `test`, `message`,
83
+ `evidence`, `fix`, plus a `summary`. If the CLI cannot be installed, use the manual grep checklist in
84
+ [references/audit-procedure.md](references/audit-procedure.md#manual-fallback).
85
+
86
+ Also look for smells the detector does not catch yet (see the "beyond the detector" list in
87
+ [references/test-smells.md](references/test-smells.md)), e.g. expected values copied from output,
88
+ tests that re-implement the algorithm, or assertions on irrelevant details.
89
+
90
+ ### Phase 2 — Verify each finding
91
+
92
+ For every finding, open the file at `line`, read the whole test, and classify it:
93
+
94
+ - **confirmed** — the test cannot fail for the bug its name implies.
95
+ - **false-positive** — the detector misread the code (explain why in one sentence).
96
+ - **accepted** — technically a smell but intentional and harmless (e.g. a deliberate smoke test).
97
+ Suggest `// honest-tests-disable-next-line <rule-id>` with a reason.
98
+
99
+ Prove the important ones. The strongest proof is a mutation that survives:
100
+
101
+ ```bash
102
+ npx @mintwork/honest-tests mutate <source file the test targets> --json
103
+ ```
104
+
105
+ Surviving mutants are concrete, line-level evidence: "changing `>=` to `>` on line 22 does not fail any
106
+ test". Mutation runs on a temporary copy of the project; your working tree is never modified. See
107
+ [references/mutation-testing.md](references/mutation-testing.md) for reading results and equivalent
108
+ mutants.
109
+
110
+ Per-rule verification notes: [references/test-smells.md](references/test-smells.md).
111
+
112
+ ### Phase 3 — Report
113
+
114
+ Lead with the headline number, then confirmed findings grouped by severity, then surviving mutants.
115
+ Use the template in [references/audit-procedure.md](references/audit-procedure.md#report-template).
116
+ When the user wants machine-readable output, return the verified findings as JSON with an added
117
+ `verdict` (`confirmed` | `false-positive` | `accepted`) and `note` per finding.
118
+
119
+ ### Phase 4 — Fix (only when asked)
120
+
121
+ Rewrite confirmed findings into honest tests following Mode 1. Re-run `audit` and `mutate`, and show the
122
+ before/after mutation score. Never "fix" a finding by deleting the assertion or adding a disable comment
123
+ without the user's agreement.
124
+
125
+ ## Rules detected by the CLI
126
+
127
+ | Rule | Severity | Catches |
128
+ | --- | --- | --- |
129
+ | `no-assertions` | error | Test body never asserts |
130
+ | `tautological-assertion` | error | `expect(x).toBe(x)`, `expect(true).toBe(true)`, expected derived from actual |
131
+ | `asserts-on-mock` | error | Asserting on the return value of a mock the test configured |
132
+ | `asserts-on-own-literal` | error | Asserting on a literal the test just built (`const p = { service: 's3' }; expect(p.service).toBe('s3')`) — typical of runtime "type tests" |
133
+ | `never-failing-assertion` | error | `expect(x).toBeTruthy` (no call), bare `expect(x)`, un-awaited `.resolves`, `typeof x` "defined", `length >= 0` |
134
+ | `swallowed-assertion` | error | Assertion inside `try` whose `catch` doesn't rethrow |
135
+ | `catch-only-assertion` | error | Assertions only in `catch` — passes when nothing throws |
136
+ | `mocked-unit-under-test` | error | `vi.mock('./cart')` in `cart.test.ts`; spying on and replacing the unit's exports |
137
+ | `focused-test` | error | `.only`, `fit`, `fdescribe` |
138
+ | `excessive-mocking` | warning | 5+ module mocks in one file |
139
+ | `snapshot-only` | warning | The only assertion is a snapshot |
140
+ | `conditional-assertion` | warning | Assertion behind `if`/`&&`/ternary |
141
+ | `skipped-test` | warning | `.skip`, `xit`, `xdescribe` |
@@ -0,0 +1,128 @@
1
+ # Audit procedure (detailed)
2
+
3
+ ## Phase 0 — Scope
4
+
5
+ - Identify the test runner (`package.json` scripts, `vitest.config.*`, `jest.config.*`) and the test
6
+ directories. Confirm the suite passes as-is: `npm test`. Mutation results are meaningless on a red
7
+ suite.
8
+ - Agree on scope with the user if the repo is large: a package, a directory, or the files touched by a
9
+ PR (`git diff --name-only main... | grep -E '\.(test|spec)\.'`).
10
+
11
+ ## Phase 1 — Detect
12
+
13
+ ```bash
14
+ npx @mintwork/honest-tests audit <paths...> --json > honest-tests.json
15
+ # useful flags
16
+ # --ignore "fixtures/**" skip paths (repeatable)
17
+ # --rule <id> / --disable <id>
18
+ # --fail-on warning|never exit code policy (default: error)
19
+ ```
20
+
21
+ Output shape (`schemaVersion: 1`):
22
+
23
+ ```jsonc
24
+ {
25
+ "schemaVersion": 1,
26
+ "tool": "honest-tests",
27
+ "version": "0.1.0",
28
+ "filesScanned": 3,
29
+ "testsScanned": 14,
30
+ "summary": { "error": 12, "warning": 3, "info": 0, "suspiciousTests": 10 },
31
+ "findings": [
32
+ {
33
+ "ruleId": "swallowed-assertion",
34
+ "severity": "error",
35
+ "file": "test/pricing.test.ts",
36
+ "line": 39,
37
+ "column": 7,
38
+ "test": "computes the discounted price",
39
+ "message": "Assertion is inside a try/catch that swallows errors ...",
40
+ "evidence": "expect(applyDiscount(100, 20)).toBe(70);",
41
+ "fix": "Remove the try/catch. For expected errors use ..."
42
+ }
43
+ ],
44
+ "unsupported": [], // test files in languages not yet supported (e.g. pytest)
45
+ "parseErrors": [],
46
+ "mutation": { ... } // present when --mutate was used
47
+ }
48
+ ```
49
+
50
+ Then do a manual pass for the "beyond the detector" smells in `test-smells.md`.
51
+
52
+ ## Phase 2 — Verify
53
+
54
+ For each finding:
55
+
56
+ 1. Open `file` at `line`; read the entire test and any helpers it calls.
57
+ 2. Check the per-rule "Verify" notes in `test-smells.md`.
58
+ 3. Assign a verdict: `confirmed`, `false-positive`, or `accepted`, with a one-sentence note.
59
+ 4. For high-impact findings, **prove** them:
60
+ - Run `npx @mintwork/honest-tests mutate <source-file>`; cite a surviving mutant on the relevant line.
61
+ - Or reason concretely: "If `applyDiscount` returned `amount` unchanged, this test would still pass
62
+ because the `catch` swallows the AssertionError."
63
+
64
+ Never modify the user's source to prove a point; `mutate` works on a temp copy.
65
+
66
+ Group duplicates: the same smell repeated across 30 tests is one finding with 30 locations.
67
+
68
+ ## Phase 3 — Report
69
+
70
+ ### Report template
71
+
72
+ ```markdown
73
+ ## Test honesty audit — <scope>
74
+
75
+ **<N> of <M> tests can't fail** (or don't test what they claim). Mutation score for
76
+ <file>: **<S>%** (<survived> of <tested> mutants survived).
77
+
78
+ ### Confirmed — errors
79
+ | Test | Rule | Why it can't fail | Fix |
80
+ | --- | --- | --- | --- |
81
+ | `pricing.test.ts:39` "computes the discounted price" | swallowed-assertion | catch eats the AssertionError; the asserted value (70) is actually wrong | drop try/catch, expect 80 |
82
+
83
+ ### Confirmed — warnings
84
+ ...
85
+
86
+ ### Surviving mutants (untested behavior)
87
+ - `pricing.ts:22` `>=` → `>` — no test checks the exact free-shipping threshold (50).
88
+ - `pricing.ts:15` `100` → `101` — no test passes 101%.
89
+
90
+ ### False positives / accepted
91
+ - `x.test.ts:12` no-assertions — smoke test for module import; accepted, added disable comment.
92
+
93
+ ### Recommended next steps
94
+ 1. ...
95
+ ```
96
+
97
+ Machine-readable variant: the `findings` array from Phase 1 with two extra fields per finding:
98
+ `"verdict": "confirmed" | "false-positive" | "accepted"` and `"note": "<one sentence>"`.
99
+
100
+ ## Phase 4 — Fix (only if asked)
101
+
102
+ - Rewrite confirmed findings following `writing-honest-tests.md`.
103
+ - Re-run `audit` (expect zero errors) and `mutate` (show before → after score).
104
+ - If a fixed test now fails, you may have found a real bug — stop and report it rather than adjusting
105
+ the expectation.
106
+
107
+ ## Manual fallback
108
+
109
+ Use this when the CLI cannot be installed.
110
+
111
+ ```bash
112
+ # focused / skipped
113
+ grep -rnE '\b(it|test|describe)\.(only|skip)\b|\b(fit|fdescribe|xit|xdescribe|xtest)\(' --include='*.test.*' --include='*.spec.*' .
114
+ # constant / self comparisons
115
+ grep -rnE 'expect\((true|false|[0-9]+|null|undefined)\)\.' --include='*.test.*' .
116
+ grep -rnE 'expect\(([A-Za-z_.]+)\)\.(toBe|toEqual|toStrictEqual)\(\1\)' --include='*.test.*' .
117
+ # matcher never called
118
+ grep -rnE 'expect\(.*\)\.(not\.)?to[A-Za-z]+;\s*$' --include='*.test.*' .
119
+ # try/catch in tests
120
+ grep -rn -A3 'try {' --include='*.test.*' . | grep -n 'expect('
121
+ # mocked unit: for each foo.test.ts, look for vi.mock/jest.mock of a path ending in /foo
122
+ grep -rnE '(vi|jest)\.mock\(' --include='*.test.*' .
123
+ # snapshot-only
124
+ grep -rn 'toMatchSnapshot\|toMatchInlineSnapshot' --include='*.test.*' .
125
+ ```
126
+
127
+ Then read each hit and apply Phase 2. For mutation without the CLI, change one operator in the source
128
+ on a scratch copy, run the tests, and restore; repeat for the most important comparisons.
@@ -0,0 +1,68 @@
1
+ # Mutation testing with honest-tests
2
+
3
+ Static rules find tests that *look* fake. Mutation testing proves whether tests *are* fake: it makes
4
+ small, deliberate bugs in the source and checks whether any test notices.
5
+
6
+ ```bash
7
+ npx @mintwork/honest-tests mutate src/pricing.ts
8
+ npx @mintwork/honest-tests audit test/ --mutate src/pricing.ts --json # both, one JSON document
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ 1. Finds the project root (nearest `package.json`) and the test files that import the target.
14
+ 2. Copies the project to a temp directory (`node_modules` is symlinked, `.git` is skipped).
15
+ **Your working tree is never written to.** The copy is deleted at the end, including on Ctrl-C.
16
+ 3. Runs the tests once unmutated (baseline). If they fail, it stops — fix the suite first.
17
+ 4. For each mutant: writes the mutated file into the copy, runs the tests, restores the file.
18
+ - tests fail → **killed** (good)
19
+ - tests pass → **survived** (the tests don't check this behavior)
20
+ - tests hang past the timeout → **timeout** (counted as killed)
21
+ 5. Reports the mutation score = (killed + timeout) / tested, and every survivor with its line.
22
+
23
+ Default test command: the package's `test` script, scoped to the related test files for vitest/jest
24
+ (`npm test --silent -- test/pricing.test.ts`). Override with `--test-cmd "pnpm vitest run {files}"`;
25
+ `{files}` expands to the related test files.
26
+
27
+ ## Mutation operators
28
+
29
+ | Operator | Example | A surviving mutant usually means |
30
+ | --- | --- | --- |
31
+ | binary | `>=` → `>`, `<` → `<=`, `===` → `!==`, `+` → `-`, `*` → `/` | boundary or arithmetic not checked with an exact value |
32
+ | logical | `&&` → `\|\|` | only one side of the condition is ever exercised |
33
+ | assignment | `+=` → `-=` | accumulation not checked |
34
+ | boolean | `true` → `false` | the flag's effect is never asserted |
35
+ | negation | `!x` → `x` | the negative branch is untested |
36
+ | number | `50` → `51`, `0` → `1` | the constant's exact value (threshold, initial value) is unchecked |
37
+ | condition | `if (c)` → `if (true)` / `if (false)` | one branch of the `if` is untested |
38
+ | return | `return x` → `return undefined` | the return value is never asserted |
39
+
40
+ Types, imports, string concatenation and `console.*`/logger calls are never mutated.
41
+
42
+ ## Reading survivors
43
+
44
+ ```
45
+ pricing.ts:22 return isMember || total >= 50;
46
+ ✗ `>=` → `>`
47
+ ✗ `50` → `51`
48
+ ```
49
+
50
+ Translate each survivor into the missing test:
51
+
52
+ - `>=` → `>` survived on `total >= 50` → add `expect(qualifiesForFreeShipping(50, false)).toBe(true)`.
53
+ - `return x` → `undefined` survived → some test calls the function but never checks the result.
54
+ - `if (c)` → `if (false)` survived → no test enters that branch with an observable effect.
55
+
56
+ ## Equivalent mutants
57
+
58
+ Some mutants don't change behavior (e.g. `i < len` → `i <= len` where the extra iteration is a no-op, or
59
+ an `if` guard that only short-circuits an optimization). They can't be killed. When you judge a mutant
60
+ equivalent, say so explicitly with a one-line reason — don't write a contrived test for it.
61
+
62
+ ## Practical tips
63
+
64
+ - Target one file at a time: the one you just wrote tests for, or the riskiest module in the audit.
65
+ - Cap long runs with `--max-mutants <n>`; use `--timeout <ms>` for slow suites.
66
+ - Gate CI with `--min-score 80` (exit 1 below the threshold).
67
+ - A score of 100% does not mean the tests are perfect; it means every simple mutation was caught.
68
+ A low score is very strong evidence that tests are fake.
@@ -0,0 +1,268 @@
1
+ # Test smell catalog
2
+
3
+ Each entry: what it looks like, why the test can't fail, the honest version, and how to verify a
4
+ finding in Phase 2 (including known false positives).
5
+
6
+ ---
7
+
8
+ ## no-assertions (error)
9
+
10
+ ```ts
11
+ it('applies a percentage discount', () => {
12
+ applyDiscount(100, 10); // runs the code, checks nothing
13
+ });
14
+ ```
15
+
16
+ **Why it's fake:** the test fails only if the code crashes. A wrong result passes.
17
+
18
+ **Honest:**
19
+
20
+ ```ts
21
+ it('takes 10% off', () => {
22
+ expect(applyDiscount(100, 10)).toBe(90);
23
+ });
24
+ ```
25
+
26
+ **Verify:** look for assertions hidden in helpers the detector didn't recognize. Calls named
27
+ `expect*`, `assert*`, `verify*`, `check*`, `ensure*`, `should*`, `validate*`, and `.expect(...)` (supertest)
28
+ already count. If the test genuinely asserts "does not throw", rewrite it as
29
+ `expect(() => fn()).not.toThrow()` so the intent is explicit.
30
+
31
+ ---
32
+
33
+ ## tautological-assertion (error)
34
+
35
+ ```ts
36
+ const result = subtotal(items);
37
+ expect(result).toBe(result); // value vs itself
38
+ expect(format(5)).toEqual(format(5)); // recomputed with the same code
39
+ expect(list).toHaveLength(list.length) // expected derived from actual
40
+ expect(true).toBe(true); // constant
41
+ ```
42
+
43
+ **Why it's fake:** the outcome is decided before any code under test runs.
44
+
45
+ **Honest:** compare against an independently known value: `expect(subtotal(items)).toBe(35)`.
46
+
47
+ **Verify:** almost never a false positive. One exception: deliberately checking referential identity of
48
+ a memoized call (`expect(memo(a)).toBe(memo(a))`) — that is a real check; mark as `accepted` and add a
49
+ comment explaining it.
50
+
51
+ ---
52
+
53
+ ## asserts-on-mock (error)
54
+
55
+ ```ts
56
+ const qualifies = vi.fn().mockReturnValue(true);
57
+ expect(qualifies(10, true)).toBe(true); // you told it to return true
58
+ ```
59
+
60
+ ```ts
61
+ vi.mocked(checkoutTotal).mockReturnValue(42);
62
+ expect(checkoutTotal(items, 0, false)).toBe(42);
63
+ ```
64
+
65
+ **Why it's fake:** it verifies the mocking library, not your code.
66
+
67
+ **Honest:** call the *real* unit, which uses the mock as a dependency, and assert on what the unit
68
+ produces. Assert on the mock only for interactions:
69
+
70
+ ```ts
71
+ const repo = { find: vi.fn().mockResolvedValue({ name: 'Ada' }) };
72
+ const svc = new UserService(repo);
73
+ expect(await svc.greet(1)).toBe('Hello, Ada');
74
+ expect(repo.find).toHaveBeenCalledWith(1);
75
+ ```
76
+
77
+ **Verify:** confirm the asserted call is the mock itself (or a variable holding its direct result),
78
+ not the real unit that happens to share a name.
79
+
80
+ ---
81
+
82
+ ## asserts-on-own-literal (error)
83
+
84
+ ```ts
85
+ it('accepts an S3 provider config', () => {
86
+ const p: ProviderConfig = { service: 's3', region: 'eu-west-1' };
87
+ expect(p.service).toBe('s3'); // reads back the literal it just wrote
88
+ expect(p).toEqual({ service: 's3', region: 'eu-west-1' });
89
+ });
90
+ ```
91
+
92
+ **Why it's fake:** nothing from the code under test is involved — the value is built from literals in
93
+ the test and read straight back. Common in "type tests": TypeScript types don't exist at runtime, so
94
+ `const p: ProviderConfig = {...}` followed by `expect(p.service)` checks nothing the compiler hasn't
95
+ already checked, and nothing at runtime at all.
96
+
97
+ **Honest:** pass the literal through the code under test and assert on what comes out:
98
+
99
+ ```ts
100
+ expect(resolveEndpoint({ service: 's3', region: 'eu-west-1' })).toBe('https://s3.eu-west-1.amazonaws.com');
101
+ ```
102
+
103
+ For a genuinely type-level contract, use a compile-time check instead of a runtime assertion:
104
+ `expectTypeOf(cfg).toMatchTypeOf<ProviderConfig>()`, `tsd`, or a `// @ts-expect-error` line that breaks
105
+ the build when the type gets too loose.
106
+
107
+ **Verify:** the rule only fires when the asserted value traces back purely to literals declared in the
108
+ same test (object/array/template literals, operators, own-property reads) and the binding is never
109
+ passed to a function, mutated or reassigned. Calls, `new`, imports, parameters and outer variables all
110
+ stop it, as do reads that could reach the prototype (`{}.polluted`, `'x' in obj`, `toHaveProperty`) and
111
+ custom matchers. If a hit is a deliberate sanity check of the test's own fixture data, delete it or
112
+ mark it `accepted`.
113
+
114
+ ---
115
+
116
+ ## never-failing-assertion (error)
117
+
118
+ ```ts
119
+ expect(config).toBeTruthy; // matcher never called
120
+ expect(load()); // no matcher at all
121
+ expect(load()).resolves.toBe(1); // not awaited/returned
122
+ expect(typeof value).toBeDefined(); // typeof is always a string
123
+ expect({ a: 1 }).toBeTruthy(); // objects are always truthy
124
+ expect(items.length).toBeGreaterThanOrEqual(0); // lengths are never negative
125
+ expect(new Cart()).toBeDefined(); // constructors always return
126
+ ```
127
+
128
+ **Why it's fake:** structurally unable to fail.
129
+
130
+ **Honest:** call the matcher, `await` async matchers, assert the exact value
131
+ (`expect(config).toEqual({ currency: 'USD' })`, `expect(items).toHaveLength(2)`).
132
+
133
+ **Verify:** for un-awaited `.resolves/.rejects`, check whether the promise is returned or awaited via a
134
+ variable later (`const p = expect(...).resolves...; await p;`) — that's a false positive.
135
+
136
+ ---
137
+
138
+ ## swallowed-assertion (error)
139
+
140
+ ```ts
141
+ try {
142
+ expect(applyDiscount(100, 20)).toBe(70); // wrong, but...
143
+ } catch {
144
+ // ...the AssertionError is caught here and the test passes
145
+ }
146
+ ```
147
+
148
+ **Why it's fake:** assertion failures are exceptions; catching them hides the failure.
149
+
150
+ **Honest:** remove the `try/catch`. For error paths: `expect(() => f()).toThrow(RangeError)`.
151
+
152
+ **Verify:** a `catch` that rethrows (`throw e`) or calls `fail()` is not flagged. If the catch logs and
153
+ then re-asserts something, read carefully — usually still confirmed.
154
+
155
+ ---
156
+
157
+ ## catch-only-assertion (error, warning if other assertions exist)
158
+
159
+ ```ts
160
+ try {
161
+ applyDiscount(100, 150);
162
+ } catch (e) {
163
+ expect(e).toBeInstanceOf(RangeError); // never runs if nothing throws
164
+ }
165
+ ```
166
+
167
+ **Why it's fake:** if the code stops throwing, the catch is skipped and the test passes with zero
168
+ assertions.
169
+
170
+ **Honest:** `expect(() => applyDiscount(100, 150)).toThrow(RangeError)`. If you need the error object,
171
+ add `expect.assertions(1)` at the top, or `expect.fail('should have thrown')` after the call.
172
+
173
+ ---
174
+
175
+ ## mocked-unit-under-test (error; warning for partial mocks)
176
+
177
+ ```ts
178
+ // checkout.test.ts
179
+ import { checkoutTotal } from '../src/checkout';
180
+ vi.mock('../src/checkout'); // the thing under test is now a stub
181
+ ```
182
+
183
+ ```ts
184
+ import * as cart from '../src/cart';
185
+ vi.spyOn(cart, 'total').mockReturnValue(3); // replaces the real implementation
186
+ ```
187
+
188
+ **Why it's fake:** every assertion runs against the mock. The real code never executes.
189
+
190
+ **Honest:** import the real module; mock its dependencies (`vi.mock('../src/tax')`, HTTP clients,
191
+ clocks).
192
+
193
+ **Verify:** the detector matches the test file name (`cart.test.ts`) to the mocked module name
194
+ (`./cart`, `../src/cart/index`). If the file is misnamed and actually tests something else, it's a
195
+ false positive. Partial mocks (`importOriginal`/`requireActual`) are warnings — check whether the
196
+ overridden export is the one being tested.
197
+
198
+ ---
199
+
200
+ ## excessive-mocking (warning)
201
+
202
+ Five or more `vi.mock`/`jest.mock` calls in one file. Little real code remains under test; the test
203
+ mostly checks that mocks are wired to each other. **Verify:** read what is left unmocked. If the unit
204
+ is a thin orchestrator, recommend an integration test at the boundary instead.
205
+
206
+ ---
207
+
208
+ ## snapshot-only (warning)
209
+
210
+ ```ts
211
+ it('renders the cart', () => {
212
+ expect(render(cart)).toMatchSnapshot();
213
+ });
214
+ ```
215
+
216
+ **Why it's weak:** the first run records whatever the code outputs — bugs included — and later runs
217
+ only detect *change*, not *wrongness*. Agents also "fix" failing snapshots with `-u` reflexively.
218
+
219
+ **Honest:** add at least one explicit assertion on what matters (`toContain('Total: $35')`), keep the
220
+ snapshot as a change detector if useful.
221
+
222
+ **Verify:** inline snapshots of small, reviewed values (`toMatchInlineSnapshot('true')`) are closer to a
223
+ literal assertion; check whether the recorded value is actually correct.
224
+
225
+ ---
226
+
227
+ ## conditional-assertion (warning)
228
+
229
+ ```ts
230
+ const cost = shippingCost(20, false);
231
+ if (cost > 0) {
232
+ expect(cost).toBeGreaterThan(0);
233
+ }
234
+ ```
235
+
236
+ **Why it's weak:** when the guard is false the test passes with nothing checked.
237
+
238
+ **Honest:** assert unconditionally on the exact value (`expect(shippingCost(20, false)).toBe(5)`).
239
+ Split data-dependent branches into separate tests.
240
+
241
+ ---
242
+
243
+ ## focused-test (error) / skipped-test (warning)
244
+
245
+ `.only`, `fit`, `fdescribe` silently disable every other test in the file. `.skip`, `xit`, `xdescribe`
246
+ inflate the test count while verifying nothing. `skipIf/runIf` are not flagged.
247
+
248
+ **Honest:** remove `.only`; fix or delete skipped tests, or convert to `it.todo('...')` with an issue.
249
+
250
+ ---
251
+
252
+ ## Beyond the detector (check these manually in Phase 1)
253
+
254
+ These are common in AI-written suites but need judgement:
255
+
256
+ - **Oracle copied from output** — the expected literal was pasted from a run of the code (`toBe(79.99)`
257
+ with no derivation). Ask: where did this number come from? Recompute it by hand.
258
+ - **Re-implemented algorithm** — the test computes the expected value with the same formula as the code
259
+ (`expect(total).toBe(price * qty * (1 - d))`). Bugs in the formula are copied into the test.
260
+ - **Asserting irrelevant details** — tests pass/fail on log text or call order nobody depends on, while
261
+ the business result is unchecked.
262
+ - **Happy path only** — no boundary (`>=` vs `>`), empty input, or error path tests. Mutation testing
263
+ exposes these as surviving `>=` → `>` mutants.
264
+ - **Test name lies** — `'rejects invalid input'` but no invalid input is passed.
265
+ - **Weak matchers everywhere** — `toBeDefined()`, `toBeTruthy()`, `toHaveBeenCalled()` (without args),
266
+ `expect.anything()` where an exact value is known.
267
+ - **Assertion-free type tests** — `const x: Foo = make()` relies on the type checker, which test runners
268
+ usually skip.