For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Chimeras

Why do my pytest tests pass for a reason I can't see?

Chimera CH-02 · pytest

Rules it tests: AR-18, AR-34, AR-35

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:

python# 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" ), )
python# 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-8

2. Where the call path hides

  • The test never asks for the fixture. autouse=True applies it to every test. The pytest docs describe autouse fixtures as a way to make all tests automatically request them. A reader of test_checkout.py sees a checkout(...) call that returns ch_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 for charge callers 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 in test_checkout.py lines 8 and 17 and in conftest.py line 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 what checkout() calls depends on how checkout.py imports it. The pytest docs say to patch the reference your code uses; from os import getcwd means patching mymodule.getcwd, not os.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:

python# before/payments/client.py from payments.gateway import ChargeResult, charge

checkout() was updated to import from the new home:

python# 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.

diff--- 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
diff--- 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_charge is in the test's signature. A reader and a coding agent see which tests use a fake and which don't, and test_checkout_rejects_an_empty_cart asserts fake_charge.calls == [], a statement the old suite couldn't make.
  • The patch is at the lookup site. checkout reads charge from its own module namespace, so that's where the fixture replaces it. A later move of the original function in gateway.py doesn't change what the fixture patches.
  • The target is a module object, not a string. monkeypatch.setattr(checkout, "charge", fake) takes the imported checkout module. Moving or renaming the module is followed by a language server rename. The attribute name is still a string, which no tool tracks, but with raising=True (the default) a missing charge in payments.checkout raises AttributeError at fixture setup, for only the tests that asked for it.
  • The fake has a real signature. FakeCharge.__call__ repeats the parameters of charge with 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_charge returns the fake; the old _no_real_charges named an intent and changed behaviour.
  • AR-35: patch where the name is looked up, through monkeypatch or mocker, 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 checkout

The 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

For agentsthis page as Markdown/agent-readable/ch-02.mdllms.txt