Skip to content
Back to skills

Test Double Scan

ASecurity

Find test doubles that lie — the code under test mocked, untyped or loose doubles, mocks of libraries the project does not own, emulated databases, test seams and test-input special cases in shipped code — each proved by a typed double, the real engine or a broken SUT.

  • 2 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 20, 2026
ai-agentstypescriptpythonrustgojavarubyphpshellbashsql

Works with

  • cli
  • api

Security analysis

A100/100

Scanned October 20, 2026

npx -y skills add adnanmokhtar/refract --skill test-double-scan --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Test Double Scan?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Test Double Scan
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/adnanmokhtar-test-double-scan/badge)](https://www.skillsdirectory.com/skills/adnanmokhtar-test-double-scan)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: test-double-scan
description: Find test doubles that lie — the code under test mocked, untyped or loose doubles, mocks of libraries the project does not own, emulated databases, test seams and test-input special cases in shipped code — each proved by a typed double, the real engine or a broken SUT.
kind: skill
pack: testing
allowed-tools: [Read, Grep, Glob, Bash]
---

# Skill: test-double-scan

## Premise

A test double stands in for something real so a test can be fast and focused. It lies in two ways. It can stand in for the **code under test** itself, so the test exercises the double and cannot fail whatever the code does. Or it can stand in for a **collaborator** while drifting from it — a method renamed on the real interface still "works" on a loose double, a mocked ORM answers in a way the real one never does, an in-memory database accepts the duplicate key Postgres rejects. Both pass green while production breaks. A third lie runs the other way: **production code that knows it is under test** — a `NODE_ENV === 'test'` branch, a literal copied from a test, an `__eq__` that always answers `True` — so the suite measures a path production never takes. In ImpossibleBench (arXiv 2510.20270, 2025-10-23), OpenAI models that cheated on impossible tasks mostly did it through operator overloading, recorded state and special-cased inputs, and LLM monitors caught only 42–65% of cheats on SWE-style tasks; the class is mechanical to find and mechanical to prove.

**Every finding cites the double or seam at `<file:line>` + the test(s) that rely on it + what it hides (the SUT, a drifted signature, an engine semantic, a production path) + the proof (or the probe that would settle it) + the fix.** A grep hit is never a finding by itself. "No lying double found" is never printed without the class-coverage block (§ Output).

**Detect statically, prove by execution.** Each class has a deterministic oracle in [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Testing: break the SUT and watch the test stay green (§ 1); type the double against the real interface and watch the compiler or `spec` name the drift (§ 2–3); run the same test on the production engine (§ 4); run the production path with the test flag off (§ 5); feed the neighbouring input and watch the special case fall off (§ 6). A candidate with no harness to run its oracle is **Unconfirmed**, carrying the exact command.

## The rule

A double or seam is a finding when all four hold:

1. **A test relies on it for its verdict.** The double sits on the path from the test's input to its assertion; a mock of a module the test never exercises (a mailer, analytics) is isolation, not a lie.
2. **It can disagree with the real thing.** The SUT is replaced; or the double's shape is unchecked (no type, no `spec`, a cast through `unknown`); or the fake engine lacks a semantic the code uses (a constraint, a lock, a commit hook); or shipped code branches on being tested.
3. **Nothing already pins the real behaviour.** Search every layer for another test that exercises the real collaborator or engine on the same behaviour — an integration or e2e test on Testcontainers covers what a unit test's ORM mock cannot. Name that test before demoting.
4. **The oracle fires.** The probe in the class's **Proof** line shows the disagreement.

## Boundaries

- **Interaction-only assertions** (`toHaveBeenCalled` where an outcome exists, `verify(…, any())`) → `assertion-strength-scan` § 2, and **a stub's value echoed back through a SUT that transforms it** → `assertion-strength-scan` § 5. The double is fine there; the assertion is weak.
- **Consumer–provider contracts** (no Pact, pacts never verified, no `can-i-deploy`, an ungated schema change) → `contract-test`; a consumed API with no contract test at all → `coverage-gap` § 6. Here: a frontend or service mock body that is **not typed or validated** from the schema (§ 3d).
- **Concurrency invariants with only single-threaded tests** (a lost update or double spend never raced) → `coverage-gap`, with the oracle in [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Invariant under concurrency; the database pack owns the production defect when installed.
- **A service with a database and no integration test at all** → `coverage-gap` § 5. **Shared or invalid fixtures** → `flake-source-scan` § 5 and `test-factories`. **Snapshot misuse** → `assertion-strength-scan` § 6.
- **On a diff**, the repo-baseline's `.claude/scripts/test-integrity.py` (when installed) is the owner of weakening in the change — but its v1 names `NODE_ENV === 'test'` branches and equality overrides as **not covered**. So § 5 and § 6 run on the diff too; dedupe by `<file:line>` with any row it does report.

## Adapt to the codebase — the lane table

Read the mock library, the runner and the ORM **with versions** from the lockfile, and the production engine from compose, Terraform or the ORM config ([`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 2, grep G1). Print the lanes you ran in the output header.

| Lane | Checked double (the fix) | Unchecked shape (the candidate) | Real engine / real dependency | Schema-checked HTTP mock |
|---|---|---|---|---|
| **TS / JS** (Jest 30, Vitest 4 / 5) | `jest.Mocked<T>`, `vi.mocked(…)`, a literal checked with `satisfies Partial<T>` before any cast | `{ … } as unknown as T`, `as any` into a constructor or a Nest `useValue`; `jest.mock` of the SUT's module | Testcontainers node 12.2.0; PGlite 0.5.8 (real Postgres SQL, one connection) | openapi-msw 2.1.0 typed handlers; `prism proxy openapi.yaml <url> --errors` (Prism 5.16.0); MSW 3.0.2 is a new major — re-check handler APIs |
| **Python** | `create_autospec(X, spec_set=True)`, `MagicMock(spec=X)` | `MagicMock()` / `Mock()` with no spec; `Mock(unsafe=True)`; `@patch` of the function then called | testcontainers-python 4.15.0; pytest-django 4.14.0 `django_capture_on_commit_callbacks` | responses / respx bodies validated against the schema |
| **Go** | mockery v3.8.0 or uber-go/mock v0.6.0 mocks generated from the interface | testify `mock.Anything`, `.Maybe()`; gomock `.AnyTimes()` | testcontainers-go v0.44.0 | `httptest` handlers built from the schema types |
| **JVM** | Mockito with `MockitoExtension` (strict stubs by default) | `RETURNS_DEEP_STUBS`, `lenient()`, mockk `relaxed = true`; a `@Spy @InjectMocks` SUT | testcontainers-java 2.0.5; a `flush()` before asserting in `@DataJpaTest` | Spring Cloud Contract 5.0.3 stubs |
| **.NET** | Moq `MockBehavior.Strict` | `MockBehavior.Loose` — Moq's default | Testcontainers .NET 4.15.0 (EF `UseInMemoryDatabase` is not a relational engine) | — |
| **Ruby / PHP** | read the double library in use; no pattern here is verified for them | — | a container of the production engine | — |

## Pre-flight greps

Run as **one** script from the repo root (shell state does not survive between tool calls). Grep **N** feeds detector **§N**; grep 0 is context. Every `rg` names its path (`.`): given no path, `rg` reads stdin whenever stdin is a pipe, and under an agent's shell that is a silent zero-hit run. Any `rg` error on stderr is a failed grep, never zero hits. `rg`'s positive `-g` globs override `-t`, so test files are selected by glob per stack family (TJ, TO), and shipped code by excluding them (XP).

```bash
X=(-g '!**/node_modules/**' -g '!**/vendor/**' -g '!**/dist/**' -g '!**/build/**' -g '!**/.next/**' -g '!**/target/**' -g '!**/.venv/**'
   -g '!**/*.min.*' -g '!**/__snapshots__/**' -g '!**/docs/**' -g '!ai/**' -g '!**/{load,loadtest,k6,benchmarks}/**')
TJ=(-g '*.{test,spec,e2e-spec,cy}.{js,jsx,ts,tsx,mjs,cjs,mts,cts}' -g '**/{__tests__,test,tests,e2e,cypress,playwright}/**/*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}')
TO=(-g 'test_*.py' -g '*_test.py' -g 'conftest.py' -g '**/tests/**/*.py' -g '*_test.go' -g '**/src/test/**/*.{java,kt}' -g '*{Test,Tests,IT}.{java,kt,cs}'
    -g '*_spec.rb' -g '**/spec/**/*.rb' -g '*_test.rb' -g '*Test.php' -g '**/tests/**/*.{php,rs}' -g '**/*.Tests/**/*.cs')
# Shipped code only (5, 6): tests, fixtures, mocks, stories, seeds, migrations and generated code dropped.
XP=(-g '!**/{test,tests,__tests__,spec,e2e,__mocks__,mocks,fixtures,__fixtures__,testdata,seeds,seeders,migrations,stories,storybook,generated,__generated__}/**'
    -g '!**/*.{test,spec,e2e-spec,cy,stories,mock,mocks,fixture,fixtures,test-data,test-utils,test-helpers}.*' -g '!**/{test_*,*_test,conftest}.*' -g '!**/*{Test,Tests,IT}.{java,kt,cs}'
    -g '!**/*.{md,mdx,json,lock,snap,yml,yaml,svg,map,sql,txt,csv}')
NC=(rg -v --pcre2 '^[^:]+:\d+:\s*(//|/\*|\*(\s|/|$)|#(?!\{)|<!--)')   # drops comment-only lines

# 0. Context, not a detector. 0a: double-reset and strictness settings in runner config; 0b: the test-side engines and
#    harnesses in the manifests (the production engine comes from refute-before-report.md § 2, grep G1).
rg -n --pcre2 '\b(?:restoreMocks|resetMocks|clearMocks|automock|mockReset|unstubGlobals)\b\s*:|\bstrictness\b|STRICT_STUBS|MockBehavior\.Strict' -g '*{jest,vitest}*.config.*' -g 'package.json' -g '{pytest.ini,pyproject.toml,setup.cfg}' -g '**/src/test/resources/**' "${X[@]}" .
rg -n --pcre2 '\b(?:testcontainers|@testcontainers/\w+|PostgreSqlContainer|MySqlContainer|MongoDBContainer|@electric-sql/pglite|pg-mem|better-sqlite3|sqlite3|mongodb-memory-server|com\.h2database|hsqldb|Microsoft\.EntityFrameworkCore\.(?:InMemory|Sqlite)|pytest-postgresql|testing\.postgresql|dockertest)\b' -g 'package.json' -g '{pyproject.toml,requirements*.txt,setup.cfg,Pipfile}' -g '{pom.xml,build.gradle*,go.mod,Gemfile,composer.json}' -g '*.csproj' "${X[@]}" .

# 1. The double replaces the SUT. 1a JS/TS: a mocked path whose basename is the test file's own subject; 1b JS/TS: a module
#    auto-mocked AND imported by the same test; 1c Python: the patched name is called in the test body; 1d JVM: a spied SUT.
rg -l0 --pcre2 '\b(?:jest|vi)\.(?:mock|doMock)\(' "${TJ[@]}" "${X[@]}" . | while IFS= read -r -d '' f; do b=$(basename "$f" | sed -E 's/\.(test|spec|e2e-spec)\.[cm]?[jt]sx?$//'); rg -Hn --pcre2 "\b(?:jest|vi)\.(?:mock|doMock)\(\s*['\"\x60][^'\"\x60]*/\Q$b\E(?:\.[cm]?[jt]sx?)?['\"\x60]" "$f"; done
rg -l0 --pcre2 '\b(?:jest|vi)\.mock\(\s*([\x27"])[^\x27"]+\1\s*\)' "${TJ[@]}" "${X[@]}" . | while IFS= read -r -d '' f; do rg -o --no-filename --pcre2 '\b(?:jest|vi)\.mock\(\s*([\x27"])\K[^\x27"]+(?=\1\s*\))' "$f" | while IFS= read -r m; do rg -Hn --pcre2 "^import\s+(?!type\b)[^;]*\bfrom\s+['\"]\Q$m\E['\"]" "$f"; done; done
rg -n -U --pcre2 '@patch(?:\.object)?\(\s*["\x27]([\w.]+)\.(\w+)["\x27][^\n]*\)\s*\n(?:\s*@[^\n]*\n)*\s*(?:async\s+)?def\s+test\w*\([^)]*\)[^\n]*:(?:\n(?!\s*(?:async\s+)?def\s)[^\n]*)*?\b\2\(|with\s+patch\(\s*["\x27][\w.]+\.(\w+)["\x27][^\n]*:\s*\n(?:\s+[^\n]*\n){0,15}?\s+[^\n]*\b\3\(' "${TO[@]}" "${X[@]}" .
rg -n -U --pcre2 '@Spy\b[^\n]*\n\s*@InjectMocks\b|@InjectMocks\b[^\n]*\n\s*@Spy\b|@(?:Spy|SpyK)\b[^\n]*\n\s*@(?:InjectMocks|InjectMockKs)\b' "${TO[@]}" "${X[@]}" .

# 2. Doubles the type system cannot check: 2a TS literals or `as any` cast to a collaborator; 2b other stacks' loose doubles.
rg -n -U --pcre2 '(?:new\s+[A-Z]\w*\([^;]{0,300}?|useValue\s*:\s*(?:\{[^{}]{0,400}\}|\w+)\s*|\}\s*\)?\s*)\bas\s+(?:any\b|unknown\s+as\s+\w*(?:Repository|Repo|Port|Service|Producer|Consumer|Gateway|Client|Adapter|Provider|Store|Queue|Bus|Publisher|Mailer|Sender|Directory|Gate|Guard|Dao|Api)\b)' "${TJ[@]}" "${X[@]}" . | rg --pcre2 '\bas\s+(?:any|unknown)\b'
rg -n --pcre2 '\b(?:Magic|Async)?Mock\(\s*\)(?!\.)|\bmock\.(?:Magic|Async)?Mock\(\s*\)|mockk<[^>]+>\(\s*relaxed\s*=\s*true|MockBehavior\.Loose|RETURNS_DEEP_STUBS|\blenient\(\)|\bmock\.Anything\b|\.Maybe\(\)|\.AnyTimes\(\)|Mock\(\s*unsafe\s*=\s*True' "${TO[@]}" "${X[@]}" . | "${NC[@]}"

# 3. Doubles of what the project does not own: 3a module mocks of drivers, SDKs and the framework; 3b casts to third-party
#    persistence and I/O types; 3c other stacks' patches of client libraries; 3d hand-written HTTP mock bodies.
rg -n --pcre2 '\b(?:jest|vi)\.mock\(\s*[\x27"](?:axios|node-fetch|undici|got|ky|fs|fs/promises|node:fs(?:/promises)?|pg|mysql2(?:/promise)?|mongoose|mongodb|@prisma/client|typeorm|sequelize|knex|drizzle-orm|@mikro-orm/[\w-]+|ioredis|redis|bullmq|@aws-sdk/[\w-]+|@google-cloud/[\w-]+|stripe|nodemailer|twilio|firebase-admin|@nestjs/[\w-]+)[\x27"]' "${TJ[@]}" "${X[@]}" .
rg -n --pcre2 '\bas\s+(?:unknown\s+as\s+)?(?:EntityManager|SqlEntityManager|EntityRepository<|Repository<|PrismaClient|PrismaService|DataSource|Knex|Redis|Queue|S3Client|Stripe|Transporter|Pool|PoolClient|Db|MongoClient)\b' "${TJ[@]}" "${X[@]}" .
rg -n --pcre2 '\bpatch(?:\.object)?\(\s*[\x27"](?:requests|httpx|urllib3?|boto3|botocore|sqlalchemy|redis|stripe|smtplib|psycopg2?|socket)\b|\bmock\(\s*(?:EntityManager|JdbcTemplate|RestTemplate|WebClient|HttpClient|Session|DataSource|Connection)(?:::|\.)class\s*\)|\bmockk<(?:EntityManager|JdbcTemplate|RestTemplate|WebClient|HttpClient)>' "${TO[@]}" "${X[@]}" .
rg -l0 --pcre2 '\bHttpResponse\.json\(\s*[\[{]|\bres\(\s*ctx\.json\(|\broute\.fulfill\(\s*\{|\bcy\.intercept\([^)\n]*,\s*\{|\bnock\([^)\n]*\)[^\n]*\.reply\(' "${TJ[@]}" "${X[@]}" . | xargs -0 -r rg -l --files-without-match --pcre2 '\bcreateOpenApiHttp\b|openapi-msw|\borval\b|\bsatisfies\s+\w+|paths\[[\x27"]/'

# 4. Engine substitution: 4a an emulated database in test config or tests; 4b the repository or ORM mocked in an
#    integration-named test; 4c commit-time behaviour a rolled-back test never reaches.
rg -n --hidden --pcre2 '\bsqlite3?\b|:memory:|jdbc:(?:h2|hsqldb):|\bnewDb\(|\bMongoMemoryServer\b|\bUseInMemoryDatabase\(|\bUseSqlite\(|\bbetter-sqlite3\b|\bpg-mem\b' "${TJ[@]}" "${TO[@]}" -g '*{test,testing}*.{properties,yml,yaml,json,cfg,ini,py,toml,env}' -g '.env.test*' -g '*{jest,vitest}*.config.*' "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 'overrideProvider\(\s*\w*(?:Repository|Repo|EntityManager|PrismaService|DataSource|Dao)\b|provide\s*:\s*(?:getRepositoryToken\([^)]*\)|\w*(?:Repository|Repo|EntityManager|PrismaService|DataSource|Dao)\b)[^\n]*useValue|\b(?:jest|vi)\.mock\([^)\n]*(?:repositor|\brepo\b|dao)\w*|@MockBean\s+(?:private\s+)?\w*(?:Repository|Dao)\b|@MockitoBean\s+(?:private\s+)?\w*(?:Repository|Dao)\b|patch(?:\.object)?\([^)\n]*(?:repository|repo|session|objects)\b' -g '*.{e2e-spec,e2e,int,integration,it}.{js,ts}' -g '**/{e2e,integration,it}/**/*.{js,ts,py}' -g '**/*IT.{java,kt}' -g '**/tests/integration/**/*.py' "${X[@]}" .
rg -l0 --pcre2 '@DataJpaTest\b|@Transactional\b' -g '**/src/test/**/*.{java,kt}' "${X[@]}" . | xargs -0 -r rg -l --files-without-match --pcre2 '\bflush\(|saveAndFlush\(|TestTransaction\.end\('
[ -n "$(rg -l --pcre2 '\bon_commit\(' -g '*.py' "${XP[@]}" "${X[@]}" . | head -1)" ] && rg -l --pcre2 '\b(?:django\.test\.)?TestCase\b' -g 'test_*.py' -g '*_test.py' -g '**/tests/**/*.py' "${X[@]}" . | xargs -r rg -l --files-without-match --pcre2 'captureOnCommitCallbacks|django_capture_on_commit_callbacks|TransactionTestCase'

# 5. Test seams in shipped code: 5a test-mode branches, stack sniffing, a NODE_ENV set membership and a guard-off switch
#    (`THROTTLE_DISABLED`, `SKIP_AUTH`); 5b test helpers and `*.test-data` / `*.fixture` files imported by shipped code
#    (those files themselves are test code, never shipped — XP drops them).
rg -n --pcre2 '\bNODE_ENV\b\s*[!=]==?\s*[\x27"\x60]test[\x27"\x60]|[\x27"\x60]test[\x27"\x60]\s*[!=]==?\s*process\.env\.NODE_ENV|\bprocess\.env\.(?:JEST_WORKER_ID|VITEST(?:_\w+)?|NODE_TEST\w*)\b|\bimport\.meta\.env\.(?:VITEST|MODE\s*[!=]==?\s*[\x27"]test)|\bsettings\.TESTING\b|[\x27"]pytest[\x27"]\s+in\s+sys\.modules|\bRails\.env\.test\?|\brunningUnitTests\(|\btesting\.Testing\(\)|\bnew\s+Error\(\)\.stack\b|\bprocess\.argv\b[^\n]*\b(?:jest|vitest|mocha|test)\b|\bif\s*\(\s*!?\s*(?:isTest(?:ing|Env|Mode)?|IS_TEST\w*)\s*(?:\(\s*\))?\s*\)|\.(?:has|includes)\(\s*(?:process\.env\.|\w+\.)?NODE_ENV\b|\b(?:[A-Z]+_)*(?:THROTTL\w*|RATE_?LIMIT\w*|CAPTCHA|CSRF|GUARDS?|SIGNATURE_CHECK|WEBHOOK_VERIFY\w*|AUTH_CHECK)_(?:DISABLED?|OFF|SKIP|BYPASS)\b|\b(?:SKIP|BYPASS)_(?:THROTTL\w*|RATE_?LIMIT\w*|AUTH\w*|CAPTCHA|CSRF|GUARDS?|SIGNATURE\w*|MFA|OTP)\b|\bDISABLE_(?:THROTTL\w*|RATE_?LIMIT\w*|CAPTCHA|CSRF|GUARDS?|SIGNATURE_CHECK)\b' "${XP[@]}" "${X[@]}" . | "${NC[@]}"
rg -n --pcre2 "from\s+['\"][^'\"]*(?:/tests?/|/__mocks__/|/fixtures/|\.(?:test|spec|test-data|test-utils|test-helpers|fixtures?|mocks?)['\"])|require\(\s*['\"][^'\"]*(?:/tests?/|/__mocks__/|\.(?:test|spec|test-data|fixtures?|mocks?)['\"])|^\s*(?:from|import)\s+tests?\.\w+" "${XP[@]}" "${X[@]}" . | "${NC[@]}"

# 6. Special-cased test inputs in shipped code: 6a equality / conversion overrides that answer a constant; 6b comparisons in
#    shipped code against a literal the tests use that appears nowhere else in shipped code (lists written to $TMPDIR).
rg -n -U --pcre2 'def\s+__eq__\s*\([^)]*\)\s*(?:->\s*bool\s*)?:\s*\n\s*return\s+True\b|\bequals\s*\([^)]*\)\s*\{\s*\n?\s*return\s+true\s*;|\[Symbol\.toPrimitive\]\s*\(|\bvalueOf\s*\(\s*\)\s*\{\s*return\s+[\d\x27"]|\bfunc\s*\(\s*\w+\s+\*?\w+\s*\)\s*Equal\([^)]*\)\s*bool\s*\{\s*return\s+true\b' "${XP[@]}" "${X[@]}" .
TD="${TMPDIR:-/tmp}/tds"; Q='(?<![\w.$#-])\d{3,}(?:\.\d+)?(?![\w.])|(?<=["\x27])[^"\x27\n\s]{3,40}(?=["\x27])'
rg -o --no-filename --pcre2 "$Q" "${TJ[@]}" "${TO[@]}" "${X[@]}" . | sort -u > "$TD-test"
rg -o --no-filename --pcre2 "$Q" "${XP[@]}" "${X[@]}" . | sort | uniq -c | awk '$1==1{print $2}' > "$TD-once"
rg -n -o --pcre2 '(?:[!=]==?|\bcase\s+)\s*\K(?:"[^"\n]{3,40}"|\x27[^\x27\n]{3,40}\x27|\d{3,}(?:\.\d+)?)(?![\w.])' "${XP[@]}" "${X[@]}" . | sed -E "s/:[\"']/:/; s/[\"']$//" | awk -F: 'FILENAME==ARGV[1]{t[$0]=1;next} FILENAME==ARGV[2]{o[$0]=1;next} {l=$0; sub(/^[^:]*:[^:]*:/,"",l)} (l in t)&&(l in o)' "$TD-test" "$TD-once" -
```

A finding is a grep hit **plus** a read of the test **plus** the double followed to the real thing it stands for **plus** the oracle run. Four blind spots are structural:

- **Test names that do not mirror the SUT.** 1a matches a mocked path whose basename is the test file's; a behaviour-named test (`a-refund-is-idempotent.spec.ts`) mocking `refund.service` is missed, and 1b catches only automocks the test also imports. For each test of a money, auth or write path, read the `jest.mock` / `patch` list against the module the test claims.
- **Doubles built in helpers.** A factory in `test/support` that returns `{…} as unknown as Repo` shows up once in 2a, however many tests use it. Count its callers before ranking it.
- **Alias paths.** `jest.mock('@/orders/orders.service')` is caught by 1a only when the alias keeps the basename; a barrel (`@/orders`) is not. Resolve aliases from `tsconfig` `paths` for the SUT modules you read.
- **6b sees comparisons only.** A special case written as a lookup (`{ 250: 3 }[cents]`), a `switch` on a computed value, or a literal split across a constant is missed. The diff form is cheaper: on a fix branch, `git diff` the shipped code for any literal that also appears in a test file.

**Measured** (2026-10-05, read-only). On a real NestJS-on-Fastify + MikroORM API (Jest 30, Testcontainers Postgres for e2e) with a React web app (Vitest 4) — about 14,200 tracked files, 3,328 JS/TS test files — the script runs in about 3 s and returns: 0a 2 (`clearMocks` in both Jest configs, no `restoreMocks`); 0b 2 (`testcontainers`, `@testcontainers/postgresql`); 1a 0 of 1,584 relative-module mocks; 1b 0; 2a 346 lines of partial doubles cast `as unknown as <Port | Repository | …>` into constructors (66 `OrderRepository`) — a drift-prone shape, settled in bulk by the conformance codemod in § The 3,000-hit path, not a finding per line; 3a 1 (`vi.mock('ky', …)`); 3b 128 casts to third-party types (`EntityManager` 106, `Redis` 10, `Queue` 6); 3d 2 e2e support files with hand-written route bodies; 4a 0 (a `pg-mem` alternation without a word boundary first matched `pg-member…` in an email literal; fixed); 5a 0 (a domain field named `isTest` matched an earlier form; the grep now keys on an `if` gate); 5b 1 (a test render helper under `src/` importing `@/test/query-wrapper`); 6a 0; 6b 26 comparisons whose literal appears once in shipped code and also in a test — all protocol method names and error codes (precedent). Python, Go and JVM lines: no such tests in the repo. On the research corpus of seeded doubles, every positive hits (the SUT mock, the automocked import, the patch-then-call, loose doubles in five stacks, H2 / SQLite `:memory:` / pg-mem) and no decoy does (`MagicMock(spec=…)`, `create_autospec`, a mailer mock, PGlite). On a scratch copy of `benchmarks/fixtures/reward-hack` with `if (cents === 250) return 3;` and a `NODE_ENV === 'test'` gate added to `src/points.js`, 6b and 5a each return exactly the seeded line; the unmodified fixtures return 0 on every grep. Re-measured 2026-10-15 on the same monorepo: the whole script 691 → 601 lines — 94 were `*.test-data.ts` / `*.fixture.tsx` files under `src/` read as shipped code (5b, 6b); XP now drops them, and 5b keeps any shipped import of one. 5a 0 → 4: the `THROTTLE_DISABLED` switch (env schema, a `.has(read.NODE_ENV)` opt-out set, the local stack script) — a seam that fails closed, so a hardening row, but no longer invisible. The fixtures are unchanged.

## The 3,000-hit path

1. **Intersect with what runs.** Keep test files a runner config collects; dismiss a probe or tool directory with one read and name it in the coverage block.
2. **Group.** One group = (the double's target type or module, the helper that builds it). 2a's 346 lines on the measured repo are about 60 groups by target type.
3. **Rank by risk surface.** Doubles of money, auth, tenant, webhook and write-path collaborators first; within those, the ones no integration or e2e test exercises for real.
4. **Read the top 30 groups fully.**
5. **Prefer the bulk oracle.** For 2a, run the conformance codemod in a scratch worktree and let the compiler read every double at once: `rg -l --pcre2 '\}\s*\)?\s*as unknown as \w' "${TJ[@]}" "${X[@]}" . | xargs perl -0pi -e 's/\}(\s*\)?)\s*as unknown as ([\w.]+(?:<[^>\n]*>)?)/}$1 satisfies Partial<$2> as unknown as $2/g'`, then `npx --no tsc --noEmit -p <the tsconfig that includes the tests>`. Each error on a rewritten line is a drifted double: measured with TypeScript 7.0.2 on 2026-10-05, a drifted return or parameter type reports TS2322 and a method name the interface no longer has reports TS2561, while the same literal behind a plain `as unknown as` cast reports nothing. Discard the worktree. For Python, the same move is `create_autospec(<Real>, instance=True)` in place of `MagicMock()` and one run of the suite.
6. **Ledger the rest.** `Live, unreviewed (k groups / n hits)` with the continuing command; never `checked`.

## Scans for

Severity: **high** when the double or seam sits on a money, auth, tenant or write path and no other layer pins the behaviour; otherwise the class default. Demote one step when another test exercises the real collaborator or engine on the same behaviour (cite it). Research classes MK-01 and MK-04…MK-06 and DB-01…DB-03 map as listed; the rest of that family (MK-02, MK-03, CT-01…04, DB-04, PY-01, FX-01…03, GM-01, NE-01…03) is routed in § Boundaries.

| § | Class | Research ids | Default |
|---|---|---|---|
| 1 | The double replaces the code under test | MK-01 | high |
| 2 | Doubles the type system or `spec` cannot check | MK-04 | medium |
| 3 | Doubles of what the project does not own; hand-written HTTP mock bodies | MK-05, MK-06 | medium; high for money or auth payloads |
| 4 | Engine substitution: an emulated database, a repository mocked in an integration test, commit-time behaviour never reached | DB-01, DB-02, DB-03 | high for DB-01 on a constraint or transaction the code relies on; else medium |
| 5 | Test seams in shipped code | — | high when the seam skips a guard, a validation or a write; else medium |
| 6 | Special-cased test inputs and equality overrides in shipped code | — | high |

### 1. The double replaces the code under test

```
BAD:   jest.mock('../src/orders.service') in orders.service.spec.ts, then expect(service.total(cart)).toBe(42)
       @patch("app.billing.pay", return_value=3) then assert pay() == 3
       @Spy @InjectMocks OrderService service; doReturn(42).when(service).total(cart)
GOOD:  mock the SUT's collaborators, never the SUT; a partial mock keeps the export under test real
       (jest.mock(path, () => ({ ...jest.requireActual(path), sendEmail: jest.fn() })))
```
**Read the code:** Which export does the test call, and is it the real one? A `requireActual` partial mock that keeps the function under test real is fine ([`precedents/testing.md`](../../../../snippets/precedents/testing.md) § 39). For a spy: is the stubbed method the one the test claims, or a collaborator method on the same object? **Finding when** the value the test asserts comes from a double, not from the code it names. **Proof:** break the SUT — make the function return a wrong value of the right type (never a `throw`) and run the test; green proves it ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Break the SUT; V70). After the fix the three probes (a constant, an empty value, the wrong member) all go red.

### 2. Doubles the type system cannot check

```
BAD:   new RefundService({ findById: jest.fn() } as unknown as OrderRepository)   // findById renamed to findOne: still compiles
       gw = MagicMock()                                                            # gw.chargee(...) "works"
       @Mock(answer = RETURNS_DEEP_STUBS) · mockk<Repo>(relaxed = true) · m.On("Save", mock.Anything).Maybe()
GOOD:  new RefundService({ findById: jest.fn() } satisfies Partial<OrderRepository> as unknown as OrderRepository)
       create_autospec(Gateway, spec_set=True) · Mockito strict stubs · MockBehavior.Strict · mockery-generated mocks
```
**Read the code:** Which methods does the SUT call on the collaborator, and does the double provide them with today's signatures? Is the double built once in a helper (count the callers)? `as any` on a deliberately invalid input to a negative test is a precedent, not a double. CPython ≥ 3.12 rejects `assert_*` typos on a mock, but not a misspelled ordinary method ([`precedents/testing.md`](../../../../snippets/precedents/testing.md) § 5). **Finding when** the double's shape differs from the real interface on a method the SUT uses, or nothing checks it and the interface changed since the double was written (`git log -L` on the interface). **Proof:** interface conformance — the codemod or `spec` in § The 3,000-hit path names the drift at the cited line ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Interface conformance, real-engine parity, env parity; V70). With no drift today, the unchecked shape alone is Hardening, fixed by typing it.

### 3. Doubles of what the project does not own

```
BAD:   jest.mock('axios') · vi.mock('ky', …) · patch("requests.post") · mock(EntityManager.class)
       { findOne: jest.fn().mockResolvedValue(null) } as unknown as EntityManager      // assumes how the ORM answers
       http.get('/api/orders', () => HttpResponse.json({ id: 1, total: '12.00' }))    // the API sends totalCents: number
GOOD:  a port the project owns (OrderReader, EmailGateway) doubled in unit tests, and the adapter tested against the
       real library (Testcontainers, a local server); handlers typed from the schema (openapi-msw), or bodies passed
       through `prism proxy` / ajv against the OpenAPI response schema
```
**Read the code:** Does the test assert behaviour that depends on the library's semantics (an ORM's null-vs-throw, an identity map, a flush, an HTTP client's retry or error shape)? Is there an e2e or integration test on the real library for the same path (then demote)? For an HTTP mock: is the body typed or validated from the schema, and do its error shapes (4xx/5xx, pagination envelope, enums, nullable fields) match? Mocking a port the project owns is the recommended shape (§ 39). **Finding when** the double encodes an assumption about third-party behaviour that a real run contradicts. **Proof:** real-engine parity — the same test against the real dependency or the schema validator fails where the double passed (V70).

### 4. Engine substitution

```
BAD:   DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": ":memory:"}}   # production is Postgres
       spring.datasource.url=jdbc:h2:mem:test;MODE=PostgreSQL · newDb() (pg-mem) · UseInMemoryDatabase()
       overrideProvider(OrderRepository).useValue({ … }) in checkout.e2e-spec.ts
       @DataJpaTest: repo.save(order); assertThat(repo.findAll()).hasSize(1)   // no flush: the unique violation never fires
GOOD:  Testcontainers pinned to the production major · PGlite for single-connection SQL semantics
       flush() / saveAndFlush() before asserting · django_capture_on_commit_callbacks(execute=True) · TransactionTestCase
```
**Read the code:** What is the production engine (G1), and does the code under test rely on a semantic the fake lacks — unique and FK enforcement (SQLite needs `PRAGMA foreign_keys=ON`), length and type checks, `ON CONFLICT`, partial indexes, JSONB, collation, `FOR UPDATE`, isolation, a Mongo transaction (needs a replica set)? Is the integration-named test mocking only external services (fine) or the repository (the finding)? Does a rolled-back test reach an `on_commit` hook or a flush-time error? SQLite in tests is fine when production is SQLite; PGlite is fine except for locking and concurrency ([`precedents/testing.md`](../../../../snippets/precedents/testing.md) § 40). **Finding when** a test's verdict depends on an engine semantic the fake does not have. **Proof:** real-engine parity — the duplicate key, over-length value or FK violation that passes on the fake fails on Testcontainers; for commit time, adding the flush or the capture makes the hidden error appear (V70; the constraint oracles in [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Constraint rejection take it from there).

### 5. Test seams in shipped code

```
BAD:   if (process.env.NODE_ENV === 'test') return true;          // in a guard
       if settings.TESTING: skip_signature_check()  ·  if (process.env.JEST_WORKER_ID) …
       import { createQueryWrapper } from '@/test/query-wrapper'   // in a module the app ships
GOOD:  inject the dependency the test wants to replace (a clock, a verifier, a transport) and pass a double in the test;
       keep test helpers under the test globs, never imported by shipped entry points
```
**Read the code:** What does the branch skip or change — a guard, a signature or validation check, a write, a delay? Is the flag set anywhere outside tests (a misconfigured production `NODE_ENV` turns the seam on in production)? For a helper import: does any shipped entry point reach that module (follow the import graph, or run the bundler's or knip's unused-file report)? **Finding when** shipped code takes a different path under test than in production on behaviour a test asserts, or ships a test-only module. **Proof:** env parity — run the production path with the flag off (`NODE_ENV=production`, `TESTING=False`) on the test's input; a different outcome proves the suite never tested the production path (V72).

### 6. Special-cased test inputs and equality overrides

```
BAD:   if (cents === 250) return 3;            // 250 is the input of the refund test, and appears nowhere else
       def __eq__(self, other): return True    ·  [Symbol.toPrimitive]() { return 42 }
       let calls = 0; export function tier(b) { return ++calls > 1 ? 'gold' : 'silver' }   // answers by call count
GOOD:  the rule the spec states, for every input; equality over the fields that define identity
```
**Read the code:** Is the literal a domain constant the tests also use (a protocol method, an error code, an enum value — precedent) or a test's specific input or expected value with no meaning in the domain? Does the override answer a constant or depend on call order? Was the line added in the same change that made a failing test pass (`git log -S'<literal>'`)? **Finding when** shipped code returns the tested answer for the tested input only. **Proof:** feed the neighbouring input (251 for 250, the same object with one field changed) to the SUT and compare with the spec's rule — the special case falls off; on a change, old test on new code: the base test against head source ([`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Old test on new code, and the anti-cheat rule; V72). A special case found in an agent's own fix diff is a tampering failure of that fix, not a finding to schedule.

## Output

```
test-double-scan — lanes: ts/jest 30.5 (apps/api), ts/vitest 4.1 (apps/web); python, go, jvm absent
Context: clearMocks only · production engine postgres 17 (compose) · e2e on @testcontainers/postgresql 12

Findings: 1 · Unconfirmed: 2

1. test/unit/refund-policy.spec.ts:31                                 [report-with-fix]  high
   §2 drifted double — { findById } as unknown as OrderRepository; the interface method is findOne(id, opts)
   since a1b2c3d. Proof: conformance codemod → TS2561 at :31; break the SUT's lookup → test still green.
   Fix: build the double from a typed helper (satisfies Partial<OrderRepository>); assert on the refund row. V70

2. test/unit/session.spec.ts:14                                        [report-flagged]   medium
   §3 vi.mock('ky') encodes ky's retry-after behaviour. Unproven link: whether an e2e test covers the
   real client. Settling probe: run the session flow against a local server returning 429 + Retry-After.

3. src/test-support/render-list.tsx:3                                 [report-flagged]   medium
   §5 test helper under src/ imports @/test/query-wrapper. Unproven link: whether a shipped entry imports it.
   Settling probe: the bundler's unused-file report or knip on the web app.

Verification: 34 candidates → 1 finding · 2 unconfirmed · 27 hardening · 0 pre-existing · 4 refuted

Class coverage:
  §1  SUT mocked                     checked (1,584 relative mocks → 0 by basename; 0 automock-and-import)
  §2  unchecked doubles              checked (346 hits / 61 groups via conformance codemod → 1 finding)
  §3  not-owned / HTTP bodies        checked (1 + 128 + 2 → 0 findings; e2e on Testcontainers covers the ORM paths) · 1 Unconfirmed
  §4  engine substitution            checked (0 hits; Testcontainers on the production major)
  §5  test seams                     checked (1 hit → 1 Unconfirmed)
  §6  special cases / overrides      checked (26 literal matches → 0 findings: protocol names and error codes)
```

`[report-with-fix]`: the double is followed to the real thing, the oracle fired, and the fix is local. `[report-flagged]`: one link is unproven (no harness, a layer not searched, an import graph not walked) and the row names it with the settling command. The coverage block lists **all six** classes on every run: `checked (k hits → m findings)` only when the grep ran on a present lane; `N/A (<reason>)` only when the repo has no such surface (no Python tests, no integration-named tests); `blocked (<reason>)` when a grep failed or code could not be read. A partial review reads `Live, unreviewed (k groups / n hits)`, never `checked`.

## False positives / precedents

The verifier reads [`precedents/testing.md`](../../../../snippets/precedents/testing.md) (§ 20–21 and 39–40 for doubles and engines) and [`refute-before-report.md`](../../../../snippets/refute-before-report.md) § 4. The ones this skill meets most:

- **Mocks of collaborators are the point of a unit test**; a `requireActual` partial mock keeps the SUT real (§ 39). A mock of a module the test never exercises is isolation.
- **An interaction on a port the project owns** is the outcome when the arguments are pinned (§ 20); a pass-through echo is `assertion-strength-scan`'s call (§ 21).
- **Engines** — Testcontainers on the production major is real; PGlite is real Postgres SQL but not locking; SQLite tests are fine for a SQLite product (§ 40).
- **A domain constant shared by test and code** — a protocol method, an error code, an enum member, a status — is not a special case.
- **`as any` to build a deliberately invalid input** for a negative test is not a double.
- **A generated client compiled in the same CI** is the frontend's contract; its mock bodies are typed by construction.

## When to run

- On any diff that adds or changes a test double, a `jest.mock` / `patch` list, a test database config, a provider override in an integration test, or a shipped line that mentions `test` — the `/review-changes` dispatch signal for test files, beside `assertion-strength-scan` and `flake-source-scan`.
- On every whole-suite test audit, reporting all six classes.
- After an interface or schema change: run 2a's conformance codemod and 3d's schema check before trusting a green unit suite.
- On every fix diff `/test-fix` or `/flaky-test-hunt` produces: § 5 and § 6 must return nothing new — a special case or seam in a fix is the anti-cheat rule failing.

## Halt conditions

- Do not report a high row as confirmed until a verifier other than the run that found it has passed it through [`refute-before-report.md`](../../../../snippets/refute-before-report.md) with [`precedents/testing.md`](../../../../snippets/precedents/testing.md). Run standalone, apply the protocol as a separate second pass, tag those rows `self-verified`, and list the rest as Unconfirmed, Hardening, Pre-existing or Refuted. Never drop them.
- Halt on any finding without the double or seam `<file:line>`, the test that relies on it, what it hides, and the fix.
- Do not report a grep hit as a finding. Do not demote a candidate to "covered elsewhere" without naming the other test and its assertion.
- Do not propose deleting the test, loosening its assertion, or a new special case as the fix. The fix makes the double honest or removes the seam.
- Do not print "no lying double found" without the class-coverage block; a class whose grep did not run is `blocked`, never `checked`.
- Do not re-derive a class another skill owns (§ Boundaries). Cross-link it.

## Related

- `assertion-strength-scan` (interaction-only and echo assertions), `flake-source-scan` (shared fixtures, real network), `contract-test` (consumer–provider contracts), `coverage-gap` (missing integration and negative tests), `mutation-probe` (break the SUT, automated), `test-factories` (typed builders that replace hand-cast doubles) — this pack.
- `/test-fix` and `/add-test` close the findings: failing-first, the oracle re-run, and the anti-cheat self-check on the fix's own diff. `/flaky-test-hunt` is the runtime harness when a lying double surfaces as a flake.
- [`proof-oracles.md`](../../../../snippets/proof-oracles.md) § Testing and [`precedents/testing.md`](../../../../snippets/precedents/testing.md) — the oracles and the settled precedents.
- `ai-patterns/test-doubles.md` — the method (mock vs stub vs fake, when each fits); this skill is its executor.
- The database pack's constraint and concurrency oracles (`proof-oracles.md` § Data) take over once § 4 shows the engine matters, when that pack is installed; without it, § 4's parity run is the proof and the constraint list in § 4 is the checklist.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.