|
| 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). |
0 commit comments