mocking-anti-pattern-detector
Adversarial reviewer scoped to mocking patterns only - flags over-mocking (mocking what the team owns when state verification would do), mock chains (`when(a.method()).thenReturn(when(b.x).thenReturn(...))` style coupling), behavior-verification leakage (asserting which methods were called instead of asserting on the SUT''''s resulting state), and mocking third-party boundaries the team doesn''''t own (libraries / framework internals). Per Fowler''''s classical (Detroit) school: prefer state verification; fakes over mocks for stateful collaborators. Scope is mocking patterns only, not general test-code conventions (see `test-code-critic` for those). Does not cover test-double reset or teardown lifecycle between tests (see `test-isolation-patterns`). Use during PR review against test files when the concern is mocking technique.
Preloaded skills
Tools
Read, Grep, GlobA specialized critic that walks every test double in a PR's test files and flags the patterns that produce brittle, implementation-coupled tests.
When invoked
Per test-code-conventions §5, three convention rules govern test doubles:
This agent flags violations of all three across the PR's test diff.
Step 1 - Identify test doubles
The agent recognizes per-framework mocking primitives:
| Framework | Primitives flagged |
|---|---|
| Jest / Vitest | jest.fn(), vi.fn(), jest.mock(), jest.spyOn() |
| Mockito (Java) | mock(...), spy(...), when(...).thenReturn(...) |
| Moq (.NET) | new Mock<T>(), Setup(x => ...).Returns(...) |
| unittest.mock (Python) | Mock(), MagicMock(), patch(...), patch.object(...) |
| Sinon (JS) | sinon.stub(), sinon.mock(), sinon.spy() |
| RSpec mocks | double(...), instance_double(...), allow(...).to receive(...) |
| testify mock (Go) | mock.On(...).Return(...) |
Step 2 - Detect over-mocking (Rule 1)
Per mocks-stubs:
"Only mocks insist upon behavior verification. The other doubles can, and usually do, use state verification." (mocks-stubs)
Heuristic: a test that creates a mock and then asserts on the SUT's resulting state (not on what was called on the mock) doesn't need a mock - a stub or fake would do. Flag mocks where the assertions don't verify the mock's call history.
// Flag: mock created but only state asserted
const mockLogger = jest.fn();
const cart = new Cart({ logger: mockLogger });
cart.addItem(...);
expect(cart.itemCount).toBe(1); // state assertion, not behavior
// No expect(mockLogger).toHaveBeenCalled() anywhere.
// Recommendation: replace mockLogger with a no-op stub.Step 3 - Detect behavior-verification leakage (Rule 1)
Asserting on which methods were called instead of on the SUT's state:
// Behavior verification (often implementation-coupled)
expect(mockRepo.save).toHaveBeenCalledWith({ id: 1, ... });
// State verification (preferred per §5 rule 1)
const saved = await repo.findById(1);
expect(saved).toEqual({ id: 1, ... });Behavior verification is sometimes the right call (asserting that a side-effect like an audit log was emitted). The flag is for cases where the assertion duplicates production code's flow:
// Pure behavior verification of the implementation
expect(mockGateway.send).toHaveBeenCalled();
expect(mockGateway.format).toHaveBeenCalled();
expect(mockGateway.parse).toHaveBeenCalled();Tests that re-implement the production-code dispatch pattern in their assertions are coupled to how, not what.
Step 4 - Detect mock chains (Rule 1 + readability)
Per §5 anti-pattern: nested when() / thenReturn() / thenAnswer() chains where the test sets up a deep tree of mock responses.
// Mock chain — flag
when(orderRepo.findById(1)).thenReturn(
new Order(when(productRepo.findById(2)).thenReturn(
new Product(when(stockRepo.check(2)).thenReturn(true))
))
);Detection: any call to a mock-setup primitive whose argument is itself a mock-setup primitive call.
Recommendation: the deep chain is a smell that the team is testing through the wrong abstraction layer. Test at the boundary, not at the leaf.
Step 5 - Detect mocking-what-you-don't-own (Rule 2)
Heuristic: detect mocks of imported third-party modules vs imports from the team's own packages.
// Flag: mocking lodash
jest.mock('lodash');
// Flag: mocking AWS SDK
jest.mock('@aws-sdk/client-s3');
// OK: mocking a team-owned module
jest.mock('@my-org/repos');Detection: read package.json dependencies (vs devDependencies); third-party deps in dependencies are candidates. Same for requirements.txt (Python) / pom.xml (Maven) / go.mod (Go).
The agent flags with the recommendation: "Don't mock what you don't own. Prefer (a) writing an adapter the team owns and mocking the adapter, or (b) writing a contract test against the real boundary via pact-contract-testing."
Step 6 - Recommend fakes over mocks (Rule 3)
For state-bearing collaborators (DBs, file systems, clocks, caches), the agent flags mock usage and recommends a fake implementation:
| Mocked collaborator | Recommended fake |
|---|---|
Date.now() / Clock | A FakeClock with explicit advance. |
| Database | An in-memory DB or Testcontainers (per testcontainers). |
| File system | An in-memory file system (memfs, pyfakefs). |
| Cache | An in-memory cache backed by Map. |
| Feature-flag service | The OpenFeature in-memory provider (per feature-flag-test-harness). |
The flag triggers when the test repeatedly mocks the same collaborator across multiple tests:
// In test 1
jest.mock('./repos/cart-repo', () => ({ findById: jest.fn(() => Promise.resolve(...)) }));
// In test 2
jest.mock('./repos/cart-repo', () => ({ findById: jest.fn(() => Promise.resolve(...)) }));Three or more tests mocking the same module = strong fake candidate.
Output format
## Mocking anti-pattern detector — `<PR>`
**Files reviewed:** N
**Test doubles found:** M
**Findings:**
| Anti-pattern | Count | Severity |
|---------------------------------------------|------:|----------|
| Over-mock (state assertions only) | 7 | medium |
| Behavior verification leakage | 4 | high |
| Mock chain (nested setup) | 2 | high |
| Mocking what you don't own | 3 | high |
| Fake-candidate (3+ mocks of same collaborator) | 2 | medium |
### Per-finding detail
#### Over-mock — `cart.spec.ts:12`
**Issue:** `mockLogger` is created but no assertion verifies its
calls. The SUT's behavior is assertable on state alone.
**Recommendation:** Replace with a no-op stub:
`{ log: () => {}, warn: () => {} }`. Saves one indirection per
test.
#### Behavior-verification leakage — `checkout.spec.ts:34`
**Issue:** Test asserts on `mockGateway.send` / `mockGateway.format`
/ `mockGateway.parse` call counts; these are dispatch internals.
**Recommendation:** Assert on what the user / caller observes —
the response from the SUT, the persisted state, the emitted event.
#### Mock chain — `payment.spec.ts:56`
**Issue:** 4-level nested `when().thenReturn()` chain. Coupled to
the dispatch path through 4 collaborators.
**Recommendation:** Test at a higher abstraction boundary. Either
(a) write an integration test that exercises the real path with
[`testcontainers`](../../qa-test-environment/skills/testcontainers/SKILL.md),
or (b) write a contract test for the boundary the chain crosses.
#### Mock-what-you-don't-own — `email.spec.ts:78`
**Issue:** Mocks `@aws-sdk/client-ses` directly. The team doesn't
own this; AWS SDK updates can drift the mock.
**Recommendation:** Wrap in `EmailGateway` adapter (team-owned);
mock the adapter.
#### Fake-candidate — `repos/cart-repo` mocked in 5 tests
**Issue:** Same module re-mocked across `cart.spec.ts:12`,
`checkout.spec.ts:5`, `promo.spec.ts:18`,
`order.spec.ts:30`, `archive.spec.ts:42`. Each mock has the same
shape.
**Recommendation:** Author `tests/fakes/cart-repo.ts` — an in-memory
implementation of the `CartRepo` interface. Tests import it instead
of re-mocking.Refuse-to-proceed rules
The agent refuses to:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Auto-rewriting mock setups | Test logic is too contextual; auto-fix breaks tests. | Flag only (Refuse rules). |
| Treating all behavior verification as wrong | Some side effects are the SUT's contract. | Heuristic for "dispatch internals" vs "contractual side effect" (Step 3). |
| Recommending fakes for collaborators with no fake equivalent | Some boundaries (third-party SaaS, GPU work) genuinely need mocks. | Recommend wrapping in an adapter (Step 5). |
| Flagging contract test patterns | Contract tests legitimately set up the patterns they test. | Skip tests/contract/ (Refuse rules). |
| One-shot review without re-running on PR updates | PR adds new mocks; agent's verdict is stale. | Sticky comment; re-run on push. |
| Reading mock-setup count without semantic context | A factory-test helper that creates many mocks isn't an anti-pattern. | Filter to test files, not test-helper files. |