Repository navigation
fix(audit): a mock of setUp or a fixture no longer makes same-named data a mock - #231
Conversation
…ata a mock The intel#103 review found that the test-quality gate flags a test that checks data as `mock_echo` when a name in it only shares a name with a mock held outside the test body: - `_add_held_by_class` handed every test of a class the bare names that any non-test method bound, locals included, and `self.m` was tracked as a bare `m`. A `setUp` with `m = MagicMock(); self.x = m`, or with `self.result = MagicMock()`, made the test's own `m = run()` or `result = run()` a mock. - `_outer_mocks` counted a fixture as a mock when its body merely called a mock factory or a patch, even when it returned data (`m = MagicMock(); return run(m)`), and a fixture of any class matched a same-named parameter in every other class. Now a mock held on self stays `self.m`, and only `self.*` names reach the tests of the class. A fixture counts when it returns or yields a mock or a patch, a fixture of a class is seen by the tests of that class (the nearest fixture of a name wins), and a parameter that `parametrize` fills or that the test rebinds is not the fixture's value. The audit of this repo's own tests is unchanged. Each new data case fails on the old helpers, and each of 12 mutants of the new code fails a test. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
@exai-bot /review |
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #231 +/- ##
==========================================
+ Coverage 92.93% 92.95% +0.02%
==========================================
Files 241 241
Lines 30582 30647 +65
Branches 5043 5058 +15
==========================================
+ Hits 28421 28489 +68
+ Misses 1459 1457 -2
+ Partials 702 701 -1 🚀 New features to boost your workflow:
|
|
@exai-bot /review |
exai-bot
left a comment
There was a problem hiding this comment.
Note
AI-generated review of a86b9ebe54cc, requested by @vchamarthi, using the default review skill. It can be wrong: verify a finding before acting on it. React with a thumbs up or down on a finding to tell the bot whether it helped.
Model us.anthropic.claude-sonnet-5-5, effort default, 12 of 94 turns, 1.0 of 48 minutes, 248k in / 6k out tokens.
The change narrows the mock-echo audit so that a name no longer counts as a mock just because a same-named mock lives elsewhere. Mocks held on self are tracked as self.m, fixtures count only when they return or yield a mock, and fixtures follow pytest scope, with parametrize and rebinding respected. The logic holds up, and I reproduced the targeted cases. The one real gap is that the new scope walk reaches fewer tests than the auditor does, so some tests silently lose the checks they had before. The tests are well aimed.
2 findings are on the changed lines, as comments below.
For a human reviewer
- Whether silently dropping detection for fixtures that return containers of mocks is acceptable, given that the gate's purpose is to catch mock echoes.
- A fixture that takes another mock fixture as a parameter and returns it (
def llm2(llm): return llm) is not tracked. That was already so onmain, so it is not a regression.
…k in a container
exai-bot's review of the previous commit found two cases that main
flagged and this branch no longer did:
- `_add_fixture_params` read only the direct body of a module or class,
while the auditor audits a test at any depth. A test, or a fixture,
under `if sys...:`, `try:` or `except ImportError:` got no fixture
mock.
- `_returns_mock` took only a returned or yielded mock itself, so a
fixture returning `{"a": MagicMock()}` or `(m, other)` counted as data.
`_scope_members` now collects a scope's functions and classes through
`if`, `try`/`except`/`finally`, `with` and loops, for the fixture walk and
for the methods `_add_held_by_class` reads. A fixture whose returned or
yielded tuple, list, set or dict holds a mock among its own items counts
as a mock; one that holds only data built from a mock stays data.
Each new case fails on the previous commit, each of 6 mutants of the fix
fails a test, and the audit of this repo's own tests is unchanged.
Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
@exai-bot /re-review |
exai-bot
left a comment
There was a problem hiding this comment.
Note
AI-generated review of 867ba75f4e17, requested by @antonwolfy, using the default review skill. It can be wrong: verify a finding before acting on it. React with a thumbs up or down on a finding to tell the bot whether it helped.
Model us.anthropic.claude-sonnet-5-5, effort default, 10 of 94 turns, 1.1 of 48 minutes, 189k in / 6k out tokens.
Re-review of 867ba75f4e17: 2 new findings.
Findings
1. Minor (confirmed, correctness): _rebound drops a mock fixture for any Store of the name, including comprehension variables
dokimi/scripts/audit_mocks.py:188
_rebound collects every ast.Name with a Store context in the test's own nodes. That includes comprehension and for targets, and the position of the store is ignored. I probed a mock-only test with an unrelated [llm for llm in run()] in an assert. llm is a mock fixture and the test asserts only llm.called. It is no longer flagged mock_echo (flags are ()). The comprehension variable has its own scope, so the fixture was not rebound. A store that comes after the last read of the parameter also counts, though the checked value is still the fixture's mock. This weakens the gate for those tests.
Suggested fix:
Skip comprehension targets, for example by walking only statement-level stores. Or count a rebind only when it comes before the first use of the name.
2. Minor (confirmed, correctness): parametrize(..., indirect=True) is treated as filling the parameter with data
dokimi/scripts/audit_mocks.py:170
With indirect=True (or indirect=['stats']) the parametrized value goes to the fixture of that name, and the test still receives the fixture's return value. _parametrized ignores indirect. A mock-returning fixture used that way is now dropped from the test's mocks. A test that asserts only llm.called is then not flagged. I probed @pytest.mark.parametrize('llm', [1], indirect=True) over a fixture returning MagicMock(), and it gives no flags. This is rare, but it is a new false negative that the PR does not mention under Risk.
Suggested fix:
Skip names covered by indirect (a truthy constant, or a list naming them) when building the parametrize set.
…trize keeps the fixture exai-bot's re-review of 867ba75 found two more cases that main flagged and this branch no longer did: - `_rebound` took any store of the name as a rebind: a comprehension's own variable (`[llm for llm in run()]`) and a store after the last read (`llm = None` at the end) dropped the fixture's mock. - `_parametrized` ignored `indirect`, so `parametrize('llm', [1], indirect=True)` counted as filling the parameter, while pytest hands the value to the fixture and the test still gets its mock. A rebind is now a store of the name, outside comprehensions, that some later read of the name follows. `parametrize` leaves out the names `indirect` hands to their fixtures: all of them for a true constant, the listed ones for a list or tuple, by keyword or as the third positional argument. Each new mock case fails on 867ba75, each of 8 mutants of the fix fails a test, and the audit of this repo's own tests is unchanged. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
Both findings of the re-review of 867ba75 are fixed in e86129d:
Each new mock case fails on 867ba75 (5 failed), and each of 8 mutants of the fix fails a test: comprehensions walked, any store counted, a store after the read counted, |
napetrov
left a comment
There was a problem hiding this comment.
Review of e86129d.
Summary: the scope fix for self.* mocks and fixtures that return mocks looks correct, and both re-review findings on 867ba75 (comprehension stores in _rebound, indirect parametrize) are fixed in e86129d with tests. I found nothing new to raise and pushed no fixes.
Test evidence (local, on e86129d):
uv run pytest dokimi/tests/test_audit_test_quality.py: 158 passed.ruff checkon the two touched files: clean. (ruff format --checkwould reformat them, but CI gates lint only, per ci.yml.)scripts/audit_test_quality.pyover the repo's tests: only the existing exempt test is reported, same as main.- Extra probes with a mock-returning fixture
llmand a patched test body: an assert ofllm.call_countalone ismock_echo. A walrus store ofllmafter the last read still givesmock_echo. A store in a nesteddefdoes not rebind the parameter (stillmock_echo).with open(...) as llmbefore the read correctly makes it data.
Open items: none.
Verdict: ready from my side.
vchamarthi
left a comment
There was a problem hiding this comment.
No blockers. 1 non-blocking comment.
No blockers. One open question on the rebound-name rule; the rest of the scoping change reads correct at e86129d.
-
Question:
_reboundtreatsllm = llm.return_valueas ending the fixture mock, soassert llm.calledis no longer flagged; should a store whose value is a mock source keep the name?audit_test_quality.py:463buildsmocks = _mock_names(fn, t.nodes) | outer, so_propagate_mocksnever sees the outer namellmand cannot mark the newllmas a mock. With@pytest.fixture def llm(): return MagicMock()anddef test_x(llm): with patch('m.y'): run(llm); llm = llm.return_value; assert llm.called,llmis stored (line 3) before its last read (line 4), so_rebounddrops it fromouter(audit_mocks.py:165) and nothing flags the assert. Before this PR the name stayed inouterand was flagged. Not run: I read this from the code only. (dokimi/scripts/audit_mocks.py:165)
CI at send time: 0 failing, 0 pending.
…self is no rebind; match and wrappers From the reviews of e86129d (vchamarthi, vlad-perevezentsev): - `_shape_flags` joined the outer mocks after `_propagate_mocks` had run, so `m = self.m` in a test did not make `m` a mock, and `_rebound` took `llm = llm.return_value` as the end of the fixture's mock. Both were flagged on main. `_mock_names` takes the outer mocks as a seed, and a store whose value is a mock read off the same name is not a rebind. - `_scope_members` did not walk `match` cases: back in. - A fixture returning `SimpleNamespace(llm=m)` or `dict(llm=m)` counted as data. Those two wrappers count as a container now. Any other call holding a mock stays data, as `run(m)` must. Each new case fails on e86129d, each of 8 mutants of the fix fails a test, and the audit of this repo's own tests is unchanged. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
intel#232 also edits tests/test_audit_test_quality.py; the merge is clean and the gate's tests pass with both. Signed-off-by: Anton Volkov <antonwolfy@gmail.com>
|
@napetrov #232 landed first, so I merged |
Summary
Follow-up to the review of #103 (thread), which #103 merged without a reply. The test-quality gate flags a test that checks data as
mock_echowhen a name in it only shares a name with a mock held outside the test body. The gate then fails on that test, and the only way out is the exemption marker. Onmaineach of these gives('mock_echo', 'trivial_size'):The same happens with
self.result = MagicMock()insetUpnext to a test-localresult = run(), and with a fixture of one class next to a same-named parameter in another class.Changes
scripts/audit_mocks.py:self.m, not as a barem: where it is assigned (_propagate_mocks), read off (_is_mock_source) and checked (_on_mock). Only theself.*names ofsetUpand the other non-test methods reach the tests of the class. A local ofsetUpis gone when the test runs._returns_mock), alone or among the items of a tuple, list, set or dict, or ofSimpleNamespace(...)ordict(...), not when its body merely calls a mock factory.if,try/except/finally,withand loops, as the auditor finds tests at any depth, and so arematchcases;_add_held_by_classreads a class's methods the same way.parametrizefills is not the fixture's value, and neither is a parameter that the test rebinds. That coversparametrizeby position or byargnames=, on the test or on its class; a nameindirecthands to its fixture keeps the fixture's value. A rebind is a store of the name, outside comprehensions, that a later read of the name follows; a store of a mock read off the same name (llm = llm.return_value) is none._mock_namestakes the mocks a test gets from outside its body as a seed, andaudit_test_quality.pypasses them, so a mock read off one (m = self.m) is a mock too.tests/test_audit_test_quality.py:mock_echo: asetUpthat puts a local mock on self, a fixture of the test's own class, a fixture that yields a patch's mock, a fixture that returns its mock in a dict or a tuple, a test, a fixture or asetUpunderif,tryorexcept, a comprehension's own variable or a store after the last read,indirectparametrize, a test undermatch, a fixture's mock inSimpleNamespace(...)ordict(...), and a mock read off the fixture's mock or off onesetUpholds.Risk
The gate stops flagging only names that never held a mock, with one exception: a fixture that wraps its mock in a dataclass or a class other than
SimpleNamespacecounts as data. A call cannot be told from a constructor by its name, and taking any call that holds a mock would makereturn run(m)a mock again.The audit of this repo's own tests is byte-identical before and after at
54ce188: 4,802 test functions, the same flags.No changelog fragment: nothing under
dokimi/dokimi/changes.Testing
On a Linux review host, at
9405920(main21558de merged in, after #232):make ci-local: 29 passed, 0 failed, 7 skipped (the Docker catalog smoke,test-latest, thecli-surfacepackaging step, conda-forge-deps without micromamba, docsdeploy, the pip-audit report, semgrep). The test job needed one rerun:test_unbalanced_quote_in_hook_completes_quicklyintests/test_repo_environment.py, a one-second timing check this PR does not touch, took 1.002 s while a secondci-localran on the host; it passed on the rerun and three times alone. 8,546 tests passed, 2 skipped. Changed-line coverage has no lines to measure: onlyscripts/andtests/change.main'saudit_mocks.py(9 failed) and pass here.e86129d(m = self.m,llm = llm.return_value, a test undermatch,SimpleNamespace(llm=m),dict(llm=...)) fail ate86129d(5 failed) and pass here. 8 mutants of that fix each fail a test: nomatchcases, no wrappers, a wrapper's keywords ignored, any call holding a mock taken as one, no seed, the seed not passed, the mock-read store taken as a rebind, and that exception widened.tests/test_audit_test_quality.pyafter mergingmainwith test(audit): pin the duplicate identity, nested test files, the exempt suffix and raises #232: 172 passed.indirect=Trueandindirect=[...]) fail at867ba75(5 failed) and pass here. 8 mutants of that fix each fail a test.if/try/except, a mock in a dict or a tuple) fail ata86b9eb(5 failed) and pass here. 6 mutants of that fix each fail a test: no descent, noexceptblocks, a class's direct body only forsetUp, no containers, a dict's keys for its values, every container a mock.setUpkept,self.mstored asm(in_self_attrand in_on_mock),_returns_mockalways true or blind toyield, the outer fixture winning over the nearer one, the class fixtures not walked, and each of theparametrizeskips (none, noargnames=, not on the class, no comma split) and the rebind skip dropped.python scripts/audit_test_quality.pyon this repo's tests: the same report as onmain, at both commits.a86b9eb, with the four follow-up branches merged together onmain54ce188 (0a5717c): the full suite (8,232 passed, 2 skipped), the growth check,mypyandruffpass.Checklist
pytestpassesruff,mypy, and schema checks pass.github/.licenserc.yaml)