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

> Chimera CH-02 (pytest): 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:

```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:

```text
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

## Rules it tests

- [AR-18: Why does monkeypatching break agent edits?](https://beta.llmgraph.in/agent-readable/ar-18.md)
- [AR-34: Why does my agent break pytest fixtures it can't see?](https://beta.llmgraph.in/agent-readable/ar-34.md)
- [AR-35: Where should I patch in pytest?](https://beta.llmgraph.in/agent-readable/ar-35.md)

Last verified 2026-10-30
