Skip to content

Commit cd8f992

Browse files
committed
mock-inspector: static analyzer for mock anti-patterns
0 parents  commit cd8f992

15 files changed

Lines changed: 1696 additions & 0 deletions

File tree

.gitignore

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
__pycache__/
2+
*.py[cod]
3+
*$py.class
4+
*.egg-info/
5+
.pytest_cache/
6+
.venv/
7+
venv/
8+
.env
9+
.coverage
10+
htmlcov/
11+
build/
12+
dist/
13+
.DS_Store
14+
.idea/
15+
.vscode/
16+
AGENTS.md
17+
.claude/
18+
CLAUDE.md

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 python-testing-debugging
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# mock-inspector
2+
3+
Static analyzer that catches `unittest.mock` / `pytest-mock` anti-patterns that
4+
**silently pass at runtime** — before they let a broken test go green.
5+
6+
Mocks fail open. A misspelled assertion, a forgotten pair of parentheses, or a
7+
signature-blind patch does not raise — it quietly asserts nothing and your suite
8+
stays green. `mock-inspector` reads your test files with the `ast` module (no
9+
imports, no execution) and points at the exact `file:line`:
10+
11+
```text
12+
$ python -m mock_inspector tests/
13+
tests/test_billing.py
14+
21:4 HIGH MI001 silent-assertion
15+
'assert_called_once' is accessed but never called - this assertion is a no-op and always passes. Add '()'.
16+
see https://python-testing-debugging.com/advanced-mocking-test-doubles-in-python/deep-dive-into-unittestmock/
17+
30:4 HIGH MI002 bogus-assertion-method
18+
'assert_called_once_with_args()' is not a real Mock assertion method - it does nothing and never fails. Did you mean a real 'assert_*' method?
19+
see https://python-testing-debugging.com/advanced-mocking-test-doubles-in-python/deep-dive-into-unittestmock/
20+
34:1 MEDIUM MI003 patch-without-autospec
21+
patch(...) without autospec=True (or spec/new_callable): the mock will accept any arguments and attribute access, hiding real bugs.
22+
see https://python-testing-debugging.com/advanced-mocking-test-doubles-in-python/autospec-strict-mocking/
23+
24+
3 findings (2 high, 1 medium)
25+
```
26+
27+
## Why this exists
28+
29+
Two of these bugs are genuinely invisible. `mock.assert_called_once` **without
30+
the parentheses** evaluates to a bound method, which is truthy, and the
31+
statement is discarded — the assertion never runs. `mock.assert_called_onse(...)`
32+
(a typo) is worse: `Mock` fabricates the attribute on access, so calling it
33+
returns another mock and never raises. Both make a test that asserts *nothing*
34+
look like a test that passes.
35+
36+
The rest are design smells that autospec and careful patch targeting prevent. A
37+
plain `patch()` returns a `MagicMock` that accepts *any* call signature, so a
38+
test keeps passing after you change (or break) the real function's arguments —
39+
which is why `autospec=True` exists. And patching a library you don't own
40+
(`patch("requests.get")`) instead of the seam where your code *uses* it is the
41+
single most common reason a patch "doesn't take"; more on choosing the right
42+
target: [where to patch](https://python-testing-debugging.com/advanced-mocking-test-doubles-in-python/patching-strategies-for-complex-codebases/where-to-patch-understanding-mock-patch-targets/).
43+
44+
## The rules
45+
46+
| ID | Name | Severity | What it catches |
47+
|----|------|----------|-----------------|
48+
| `MI001` | silent-assertion | HIGH | A `assert_*` assertion accessed as an attribute but never called (no-op). |
49+
| `MI002` | bogus-assertion-method | HIGH | A called `assert_*` method that Mock does not define (typo → always passes). |
50+
| `MI003` | patch-without-autospec | MEDIUM | `patch()` / `patch.object()` with no `autospec` / `spec` / `new` / `new_callable`. |
51+
| `MI004` | mocking-what-you-dont-own | MEDIUM | `patch("<stdlib-or-third-party>.…")` instead of your own seam. |
52+
| `MI005` | unused-mock | LOW | A mock created (or bound via `as`) but never referenced again in the function. |
53+
| `MI006` | return_value-and-side_effect | MEDIUM | Both `return_value` and `side_effect` set on one mock (`side_effect` silently wins). |
54+
55+
## Running it
56+
57+
No install required — run it straight from a clone:
58+
59+
```bash
60+
git clone https://github.com/python-testing-debugging/mock-inspector
61+
cd mock-inspector
62+
python -m mock_inspector path/to/your/tests/
63+
```
64+
65+
Python 3.9+ and the standard library only — no third-party dependencies. On
66+
Python 3.10+ the stdlib module list (`sys.stdlib_module_names`) powers `MI004`;
67+
on 3.9 that rule falls back to the curated third-party list only.
68+
69+
You can also install the console script if you prefer (`pip install -e .` gives
70+
you a `mock-inspector` command), but the documented, canonical way is running
71+
the module from source.
72+
73+
### Options
74+
75+
| Flag | Effect |
76+
|------|--------|
77+
| `--select MI001,MI003` | Run only the listed rules. |
78+
| `--ignore MI005` | Run everything except the listed rules. |
79+
| `--format {text,json}` | Output format (default `text`). JSON is machine-readable for CI. |
80+
| `--stats` | Print over-mock density (mocks per test function) instead of findings. |
81+
| `--exit-zero` | Report findings but always exit `0` (soft CI gate). |
82+
| `--third-party mod,mod` | Extra top-level modules to treat as "not yours" for `MI004`. |
83+
| `--own mod,mod` | Top-level modules to treat as owned (suppress `MI004` for them). |
84+
| `--no-color` | Disable ANSI color even on a TTY. |
85+
| `--list-rules` | Print the rule catalog and exit. |
86+
87+
## How it works
88+
89+
1. **Discover** every `*.py` file under the given paths (recursively); files are
90+
de-duplicated and processed in a stable order.
91+
2. **Parse** each file into an AST with `ast.parse`. A file that does not parse
92+
is reported on stderr and skipped — the run continues.
93+
3. **Walk once** for the call/statement rules: a bare `assert_*` attribute
94+
statement (`MI001`), a called assert method that is not in the canonical Mock
95+
set (`MI002`, matched with a prefix + edit-distance heuristic so real helpers
96+
like `assert_frame_equal` are left alone), signature-blind `patch()` calls
97+
(`MI003`), library-boundary patch targets (`MI004`), and one call carrying
98+
both `return_value` and `side_effect` (`MI006`).
99+
4. **Walk per function** for the scoped rules: a mock name that is never read
100+
again (`MI005`), and `return_value` / `side_effect` split across two
101+
attribute assignments on the same object (`MI006`).
102+
5. **Filter** the findings through `--select` / `--ignore`, then render them as
103+
text grouped by file (with a severity summary) or as JSON.
104+
105+
## Exit codes
106+
107+
| Code | Meaning |
108+
|------|---------|
109+
| `0` | Clean (or `--exit-zero` was passed). |
110+
| `1` | At least one finding was reported. |
111+
| `2` | Usage error, or a file could not be read/parsed and nothing else was found. |
112+
113+
## Demo
114+
115+
```bash
116+
# Lists a finding for every rule MI001-MI006:
117+
python -m mock_inspector examples/bad_mocks.py
118+
119+
# The corrected version is clean (exit 0):
120+
python -m mock_inspector examples/good_mocks.py
121+
```
122+
123+
`examples/bad_mocks.py` is a commented catalog of each anti-pattern;
124+
`examples/good_mocks.py` is the minimal fix for each one, side by side.
125+
126+
## Background reading
127+
128+
`mock-inspector` grew out of the mocking material on
129+
[python-testing-debugging.com](https://python-testing-debugging.com), which digs
130+
into why these mistakes pass silently and how to structure test doubles so they
131+
don't. Worth reading alongside the rules above:
132+
133+
- [Advanced mocking & test doubles in Python](https://python-testing-debugging.com/advanced-mocking-test-doubles-in-python/)
134+
- [A deep dive into unittest.mock](https://python-testing-debugging.com/advanced-mocking-test-doubles-in-python/deep-dive-into-unittestmock/)
135+
- [Autospec & strict mocking](https://python-testing-debugging.com/advanced-mocking-test-doubles-in-python/autospec-strict-mocking/)
136+
- [Where to patch: understanding mock.patch targets](https://python-testing-debugging.com/advanced-mocking-test-doubles-in-python/patching-strategies-for-complex-codebases/where-to-patch-understanding-mock-patch-targets/)
137+
138+
## License
139+
140+
MIT — see [LICENSE](LICENSE).

examples/bad_mocks.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""A teaching artifact: every line below is a mock anti-pattern.
2+
3+
Running ``python -m mock_inspector examples/bad_mocks.py`` should report at
4+
least one finding for each rule MI001-MI006. None of these mistakes raises an
5+
error at runtime -- that is precisely why they are dangerous: the tests look
6+
green while asserting nothing (or asserting the wrong thing).
7+
8+
This file is intentionally *not* meant to be executed as a test suite; it is a
9+
catalog of bugs for the linter to catch.
10+
"""
11+
12+
from unittest import mock
13+
from unittest.mock import MagicMock, Mock, patch
14+
15+
16+
def test_mi001_silent_assertion():
17+
service = MagicMock()
18+
service.charge(42)
19+
# BUG (MI001): missing parentheses. This accesses the assertion method as
20+
# an attribute and immediately discards it -- it never actually asserts.
21+
service.charge.assert_called_once
22+
service.charge.assert_called_with
23+
24+
25+
def test_mi002_bogus_assertion_method():
26+
sender = MagicMock()
27+
sender.send("hi")
28+
# BUG (MI002): there is no such method. Mock auto-creates it as a child
29+
# mock, so the "assertion" happily returns another mock and never fails.
30+
sender.send.assert_called_once_with_args("hi")
31+
sender.assert_has_call("hi")
32+
33+
34+
@patch("myapp.services.mailer") # BUG (MI003): no autospec -> accepts any call
35+
def test_mi003_patch_without_autospec(mailer):
36+
mailer.deliver(subject="hi", body="there", bogus_kwarg=123)
37+
mailer.deliver.assert_called_once()
38+
39+
40+
def test_mi004_mocking_what_you_dont_own():
41+
# BUG (MI004): patching the stdlib boundary instead of your own seam.
42+
with patch("os.getcwd", return_value="/tmp"):
43+
pass
44+
# BUG (MI004): third-party library boundary.
45+
with patch("requests.get") as _resp:
46+
_resp.return_value.json.return_value = {}
47+
48+
49+
def test_mi005_unused_mock():
50+
# BUG (MI005): created and then never referenced again. The assertion the
51+
# author meant to write (orphan.something.assert_called()) is missing.
52+
orphan = MagicMock()
53+
result = 1 + 1
54+
assert result == 2
55+
56+
57+
def test_mi006_return_value_and_side_effect():
58+
# BUG (MI006, call form): side_effect wins; return_value is ignored.
59+
client = Mock(return_value=200, side_effect=ConnectionError)
60+
# BUG (MI006, attribute form): same footgun spelled out over two lines.
61+
fetch = mock.MagicMock()
62+
fetch.return_value = {"ok": True}
63+
fetch.side_effect = TimeoutError
64+
return client, fetch

examples/good_mocks.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""The corrected version of ``bad_mocks.py``.
2+
3+
Every anti-pattern has been fixed, so ``python -m mock_inspector`` reports
4+
nothing here. Compare this file with ``bad_mocks.py`` side by side to see the
5+
minimal change each rule asks for.
6+
"""
7+
8+
from unittest import mock
9+
from unittest.mock import MagicMock, patch
10+
11+
12+
def test_mi001_silent_assertion():
13+
service = MagicMock()
14+
service.charge(42)
15+
# FIXED (MI001): actually call the assertion.
16+
service.charge.assert_called_once()
17+
service.charge.assert_called_with(42)
18+
19+
20+
def test_mi002_bogus_assertion_method():
21+
sender = MagicMock()
22+
sender.send("hi")
23+
# FIXED (MI002): use the real method names.
24+
sender.send.assert_called_once_with("hi")
25+
sender.send.assert_has_calls([mock.call("hi")])
26+
27+
28+
@patch("myapp.services.mailer", autospec=True) # FIXED (MI003): autospec on
29+
def test_mi003_patch_without_autospec(mailer):
30+
mailer.deliver(subject="hi", body="there")
31+
mailer.deliver.assert_called_once()
32+
33+
34+
def test_mi004_mocking_what_you_dont_own():
35+
# FIXED (MI004): patch where the object is *used* in your own code, and
36+
# give it a spec so it cannot drift from the real signature.
37+
with patch("myapp.clients.session", autospec=True) as session:
38+
session.get.return_value.json.return_value = {}
39+
assert session.get is not None
40+
41+
42+
def test_mi005_unused_mock():
43+
# FIXED (MI005): the mock is actually exercised and asserted on.
44+
dependency = MagicMock()
45+
dependency.configure_mock(name="used")
46+
dependency.ping()
47+
dependency.ping.assert_called_once()
48+
49+
50+
def test_mi006_return_value_and_side_effect():
51+
# FIXED (MI006): pick exactly one behavior.
52+
client = MagicMock(side_effect=ConnectionError)
53+
fetch = mock.MagicMock()
54+
fetch.return_value = {"ok": True}
55+
return client, fetch

mock_inspector/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""mock-inspector: a static analyzer for unittest.mock / pytest-mock misuse.
2+
3+
Scans test files with the :mod:`ast` module (no imports, no execution) and
4+
reports mock anti-patterns that silently pass at runtime.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
__version__ = "0.1.0"

mock_inspector/__main__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Enable ``python -m mock_inspector``."""
2+
3+
from __future__ import annotations
4+
5+
from mock_inspector.cli import main
6+
7+
raise SystemExit(main())

0 commit comments

Comments
 (0)