Agent Rules / Chimeras
Why do my pytest tests pass for a reason I can't see?
An autouse fixture in conftest.py replaces a function for every test, and no test mentions it. When an ordinary refactor moves that function, the fixture can keep succeeding while it patches a name the code no longer looks up, and the tests fail somewhere else with a traceback that never touches the test file. Name the fixture, request it in the tests that need it, and patch the module that does the lookup.
The code below is from a small example project we wrote for this page. The break below is read from the files, by line.
1. The setup
pytest makes this pattern easy, and the parts are good ones. monkeypatch undoes every change when the test ends, conftest.py shares a fixture across a directory, and autouse=True removes boilerplate. The goal here is reasonable: no test should reach the card gateway.
The package has a gateway client, a checkout function and three tests. Before the refactor, charge lived in payments/client.py and checkout() called client.charge(...). The fixture in before/tests/conftest.py patches it for every test:
# before/tests/conftest.py
@pytest.fixture(autouse=True)
def _no_real_charges(monkeypatch):
monkeypatch.setattr(
"payments.client.charge", # line 9
lambda customer_id, amount_cents, currency="usd": ChargeResult(
ok=True, charge_id="ch_test"
),
)# before/tests/test_checkout.py
def test_checkout_returns_receipt_for_the_cart_total():
receipt = checkout("cus_42", [1200, 800])
assert receipt == Receipt(charge_id="ch_test", amount_cents=2000) # lines 7-82. Where the call path hides
- The test never asks for the fixture.
autouse=Trueapplies it to every test. The pytest docs describe autouse fixtures as a way to make all tests automatically request them. A reader oftest_checkout.pysees acheckout(...)call that returnsch_test, and no line says where that value comes from. - The target is a string.
"payments.client.charge"is resolved at runtime. A rename in a language server or a text search forchargecallers doesn't follow it to the patched function, and the fixture isn't listed as a caller of anything. - The magic value lives in the lambda.
"ch_test"appears intest_checkout.pylines 8 and 17 and inconftest.pyline 11. Change the fixture and two tests change meaning without being edited. - The patch point is a module, not the use. The fixture patches the module that defined
charge. Whether that changes whatcheckout()calls depends on howcheckout.pyimports it. The pytest docs say to patch the reference your code uses;from os import getcwdmeans patchingmymodule.getcwd, notos.getcwd.
3. The edit that breaks it, and which call is left wrong
The refactor moves the HTTP code to its own module. charge now lives in payments/gateway.py:17. To keep old imports working, payments/client.py:5 re-exports it:
# before/payments/client.py
from payments.gateway import ChargeResult, chargecheckout() was updated to import from the new home:
# before/payments/checkout.py
from payments.gateway import charge # line 3 (was: from payments import client)
...
result = charge(customer_id, total) # line 24 (was: client.charge(...))Every import still resolves and the code reads fine. The fixture is the call left wrong: conftest.py:9 still patches payments.client.charge. That attribute exists (the re-export at client.py:5), so monkeypatch.setattr succeeds without complaint. It replaces the alias in payments.client. checkout.py no longer reads payments.client; it reads its own name charge, bound at line 3 to the function in gateway.py. The patch changes an object no code path uses.
What the test run shows is a failure far from the cause. checkout("cus_42", [1200, 800]) at test_checkout.py:7 reaches the real charge, whose first statement is os.environ["PAYMENTS_API_KEY"] (gateway.py:18). On a machine without that variable, the traceback ends in KeyError inside gateway.py; with it set, the test would attempt a real HTTPS call. The test file and the fixture file both look correct. The test test_checkout_rejects_an_empty_cart (lines 11-13) never reaches charge, so it still passes and suggests the suite is fine.
If the refactor had renamed charge instead of moving it, the same setattr would raise AttributeError, since the pytest docs say raising=True (the default) raises when the target does not exist. That one is loud, but it fails every test in the directory from a fixture none of them name.
4. The fix
Make the dependency visible. A named fixture, not autouse, requested by the tests that need it, patching the object the code under test looks up (payments.checkout.charge), through monkeypatch.setattr on the module object, with a typed fake that records its calls.
--- before/tests/conftest.py+++ after/tests/conftest.py import pytest -from payments.client import ChargeResult+from payments import checkout+from tests.fakes import FakeCharge -@pytest.fixture(autouse=True)-def _no_real_charges(monkeypatch):- monkeypatch.setattr(- "payments.client.charge",- lambda customer_id, amount_cents, currency="usd": ChargeResult(- ok=True, charge_id="ch_test"- ),- )+@pytest.fixture+def fake_charge(monkeypatch: pytest.MonkeyPatch) -> FakeCharge:+ fake = FakeCharge()+ # checkout.py does "from payments.gateway import charge", so the name+ # that checkout() looks up lives in payments.checkout.+ monkeypatch.setattr(checkout, "charge", fake)+ return fake--- before/tests/test_checkout.py+++ after/tests/test_checkout.py-def test_checkout_returns_receipt_for_the_cart_total():- receipt = checkout("cus_42", [1200, 800])- assert receipt == Receipt(charge_id="ch_test", amount_cents=2000)+def test_checkout_charges_the_cart_total(fake_charge: FakeCharge):+ receipt = checkout("cus_42", [1200, 800])+ assert fake_charge.calls == [("cus_42", 2000, "usd")]+ assert receipt == Receipt(charge_id="ch_test", amount_cents=2000)after/tests/fakes.py holds FakeCharge, a callable class with the same parameters as gateway.charge. A third test, test_checkout_raises_when_the_card_is_declined, sets fake_charge.result to a declined ChargeResult, which the old lambda couldn't express.
5. Why the fix works
- The test names its dependency.
fake_chargeis in the test's signature. A reader and a coding agent see which tests use a fake and which don't, andtest_checkout_rejects_an_empty_cartassertsfake_charge.calls == [], a statement the old suite couldn't make. - The patch is at the lookup site.
checkoutreadschargefrom its own module namespace, so that's where the fixture replaces it. A later move of the original function ingateway.pydoesn't change what the fixture patches. - The target is a module object, not a string.
monkeypatch.setattr(checkout, "charge", fake)takes the importedcheckoutmodule. Moving or renaming the module is followed by a language server rename. The attribute name is still a string, which no tool tracks, but withraising=True(the default) a missingchargeinpayments.checkoutraisesAttributeErrorat fixture setup, for only the tests that asked for it. - The fake has a real signature.
FakeCharge.__call__repeats the parameters ofchargewith types, so a reader can compare the two. Whether a type checker also compares the fake against the real function depends on the settings in use and isn't claimed here. - Cleanup is unchanged. The pytest docs state that all modifications are undone after the requesting test function or fixture has finished.
6. Rules that apply
- AR-18: no monkeypatching in production code; in tests, patch through the fixture and mock API at the import site.
- AR-34: fixtures in the nearest
conftest.py, named for what they return, with no autouse for behaviour changes.fake_chargereturns the fake; the old_no_real_chargesnamed an intent and changed behaviour. - AR-35: patch where the name is looked up, through
monkeypatchormocker, never by reassigning module globals.
7. What LLMGRAPH shows
Asked who calls charge in before/, LLMGRAPH names the one real caller and proves it:
llmgraph · who_calls charge (payments/gateway.py:17): 1 certain
✓ certain payments/checkout.py:24 checkoutThe conftest patch is a string target handed to monkeypatch.setattr, not a call, so it isn't in the caller list, and LLMGRAPH doesn't read string-target patches. That gap is exactly why the fixture in after/ patches checkout.charge on the module object: the patch target is now code that every tool can follow.
8. References
pytest (fetched 2026-10-30):
- Fixtures how-to (autouse fixtures, sharing through
conftest.py, overriding by name): https://docs.pytest.org/en/stable/how-to/fixtures.html - Monkeypatch how-to (
monkeypatch.setattr,raising, patching the reference your code uses, undo at the end of the test): https://docs.pytest.org/en/stable/how-to/monkeypatch.html
Last verified