# Where should I patch in pytest?

> Rule AR-35: Patch the name the code under test looks up, through monkeypatch or mocker, on the module object. Don't reassign module globals in a test.

`monkeypatch.setattr` swaps a name for the length of one test and puts it back afterwards. That makes it a good tool, but it only works on the name your code reads. If `checkout.py` does `from payments.gateway import charge`, then patching `payments.gateway.charge` changes an object `checkout` no longer reads. The general rule is on [AR-18](https://beta.llmgraph.in/agent-readable/ar-18.md). This page covers how pytest applies it: which fixture to use, what the arguments mean, and what fails loudly and what doesn't.

## Why it happens
Python binds a name where it is imported. After `from payments.gateway import charge`, `checkout` holds its own reference to the function, and replacing the attribute in `payments.gateway` doesn't touch it. The mock docs put the rule this way: "you patch where an object is _looked up_, which is not necessarily the same place as where it is defined." The pytest docs say the same thing for `monkeypatch`: "Prefer patching the reference that your code uses instead of patching the original object in the standard library."

What pytest gives you, from the monkeypatch how-to and the reference:

- **Undo is automatic.** "All modifications will be undone after the requesting test function or fixture has finished."
- **`setattr(target, name, value, raising=True)`** replaces an attribute. With `raising=True`, "Raises AttributeError if the attribute does not exist". Pass `raising=False` and a patch aimed at a missing name succeeds without effect on anything.
- **The target can be an object or a dotted string.** The reference: "you can specify a string as target which will be interpreted as a dotted import path", for example `monkeypatch.setattr("os.getcwd", lambda: "/")`. With the object form, `monkeypatch.setattr(checkout, "charge", fake)`, the module is a real reference, and a rename tool follows it. The attribute name is still a string.
- **Other methods:** `delattr`, `setitem`, `delitem`, `setenv`, `delenv`, `syspath_prepend`, `chdir` and `context()`.
- **Builtins.** "It is not recommended to patch builtin functions such as `open`, `compile`, etc.", because pytest's own internals use them. `monkeypatch.context()` limits a patch to a block.
- **Outside a test.** `MonkeyPatch.context()` returns a new object "which undoes any patching done inside the with block", and `pytest.MonkeyPatch()` can be used directly "for when the fixture is not available". For a fixture with a scope wider than `function`, use `MonkeyPatch.context()` inside it The monkeypatch how-to does not label the fixture's scope, but says "The same `monkeypatch` fixture is used across a single test function invocation" and that changes are undone after "the requesting test function or fixture has finished" (https://docs.pytest.org/en/stable/reference/reference.html), which describes function scope.

## Bad and good
`payments/checkout.py` does `from payments.gateway import charge` and calls `charge(customer_id, total)`.

```python
# bad: tests/test_checkout.py
import payments.gateway
from payments.checkout import checkout


def test_checkout_charges_total():
    original = payments.gateway.charge
    payments.gateway.charge = lambda *a, **k: payments.gateway.ChargeResult(ok=True, charge_id="ch_test")
    try:
        receipt = checkout("cus_42", [1200, 800])
    finally:
        payments.gateway.charge = original
    assert receipt.charge_id == "ch_test"
```

This has three faults. It reassigns a module global by hand and relies on `finally` for the restore. It patches `payments.gateway`, which `checkout` doesn't read. And the lambda takes any arguments, so it accepts a call the real function would reject. The test runs the real `charge` and fails or goes to the network.

```python
# good: tests/test_checkout.py
from unittest import mock

import pytest

from payments import checkout as checkout_module
from payments import gateway


@pytest.fixture
def charge_mock(monkeypatch: pytest.MonkeyPatch) -> mock.Mock:
    fake = mock.create_autospec(gateway.charge)
    fake.return_value = gateway.ChargeResult(ok=True, charge_id="ch_test")
    # checkout.py did "from payments.gateway import charge": patch its own name.
    monkeypatch.setattr(checkout_module, "charge", fake)
    return fake


def test_checkout_charges_total(charge_mock: mock.Mock):
    receipt = checkout_module.checkout("cus_42", [1200, 800])
    charge_mock.assert_called_once_with("cus_42", 2000, "usd")
    assert receipt.charge_id == "ch_test"
```

`monkeypatch.setattr(checkout_module, "charge", fake)` names the module that looks the name up, restores it at the end of the test, and with the default `raising=True` fails at setup if `checkout` has no `charge`. `create_autospec` gives the stand-in the real signature: the mock docs say functions and methods "will have their arguments checked to ensure that they are called with the correct signature."

With the `pytest-mock` plugin the same patch is written through the `mocker` fixture:

```python
def test_checkout_charges_total(mocker):
    charge = mocker.patch("payments.checkout.charge", autospec=True)
    charge.return_value = gateway.ChargeResult(ok=True, charge_id="ch_test")
    ...
```

`mocker` comes from the pytest-mock plugin. Its usage page: "The `mocker` fixture has the same API as mock.patch, supporting the same arguments", and its examples use `mocker.patch.object(os, 'listdir', autospec=True)` (https://pytest-mock.readthedocs.io/en/latest/usage.html). The target string here follows the same where-to-look-it-up rule.

## Trade-offs and when the other way is fine
- **`monkeypatch` needs no extra package.** It ships with pytest, and covers attributes, dict items, environment variables, the working directory and `sys.path`. Use it for those. It doesn't give signature checking by itself, so pass it a `create_autospec` object or a typed fake class when the signature matters.
- **`mocker` adds call recording and `autospec` in one call.** It is a plugin, so it is one more dependency: the plugin's page says "This pytest plugin provides a `mocker` fixture" and installs with `pip install pytest-mock` (https://pytest-mock.readthedocs.io/en/latest/). If the suite already uses `unittest.mock`, `mock.patch` as a context manager does the same job with the standard library.
- **A typed fake class beats either for a service used in many tests.** CH-02 uses a `FakeCharge` callable. The test asserts on `fake.calls` and a checker can compare the fake's `__call__` to the real function.
- **A dotted string is shorter, and a module object is easier to follow.** Both undo the same way. Prefer the object form when the module is already imported in the test.
- **Dependency injection removes the patch.** If `checkout` takes `charge` as a parameter with a default, the test passes a fake and nothing is patched. That costs a parameter in the code under test (AR-18).
- **Fixture scope matters.** A function-scoped patch fixture is isolated. A wider scope shares one patch across tests, and with `monkeypatch` that needs `MonkeyPatch.context()` as above. Use that only for patches no test changes.

## Enforce it
| Tool | Setting | What it catches | Checked |
|---|---|---|---|
| pytest | `monkeypatch.setattr(..., raising=True)` (the default) | a patch aimed at a name that doesn't exist; a moved or renamed target fails at setup | 2026-10-30 |
| unittest.mock | `patch(..., autospec=True)` or `create_autospec(fn)` | a call whose arguments the real signature rejects raises `TypeError` | 2026-10-30 |
| ruff | `PT008` pytest-patch-with-lambda (select the `PT` family) | a lambda as the replacement in a patch where `return_value` fits. A lambda that uses its arguments is not flagged | 2026-10-30 (from AR-18) |
| ruff | `PT019` pytest-fixture-param-without-value | a test taking a patch fixture only for its effect; use `@pytest.mark.usefixtures("name")` | 2026-10-30 |
| pytest | `pytest --fixtures` | lists available fixtures | "Show available fixtures, sorted by plugin appearance" (https://docs.pytest.org/en/stable/reference/reference.html) |

Nothing in the pages read checks that a patch target is the lookup site rather than the definition site. A patch at the wrong site with `raising=True` still succeeds when the attribute exists, which is the case in CH-02. The way to catch it is a test that asserts the mock was called (`assert_called_once_with`), so a patch that doesn't take effect fails the test.

## Rule block for AGENTS.md / CLAUDE.md
```markdown
## pytest patching
- Patch the name the code under test looks up. If checkout.py has "from payments.gateway import charge", patch payments.checkout.charge. If it has "import payments.gateway" and calls payments.gateway.charge, patch payments.gateway.charge.
- Use the monkeypatch fixture (or mocker). Never assign to module attributes in a test body, and never write try/finally to restore them.
- Prefer monkeypatch.setattr(module_object, "name", fake) over a dotted string when the module is already imported. Keep raising=True (the default).
- Give replacements the real signature: create_autospec(real_fn) or patch(..., autospec=True), or a typed fake class. No bare lambdas that accept any arguments.
- Every patched call gets an assert on how it was called, so a patch that missed its target fails the test.
- Patch builtins only inside monkeypatch.context(). Don't patch open or compile in a fixture that spans tests.
- When moving a function or changing an import in production code, search the tests for the old dotted path and the old module's name in patch calls, and update them in the same change.
```

## Edge cases
- **`from x import y` captured before the patch.** The module that already imported `y` keeps the real one. Patch the importer's name.
- **Class attributes and methods.** `monkeypatch.setattr(SomeClass, "method", fake)` replaces it for every instance until undo. The mock docs show that autospec copies the real signature, and that `create_autospec(spec, instance=True)` uses a class as the spec for an instance object; with `instance=False`, a class spec gives a mock whose return value (the instance) has the same spec (https://docs.python.org/3/library/unittest.mock.html).
- **Re-exports.** The failure in CH-02: a re-export in `payments/client.py` kept `monkeypatch.setattr("payments.client.charge", ...)` succeeding while `checkout` read a different name.
- **`raising=False`.** Use it only for names that legitimately may not exist, such as an optional attribute on a third-party object. It turns a misspelt target into silence.
- **Environment and time.** `setenv`, `delenv` and `chdir` change inputs, not calls, and are fine in a named fixture (AR-34).
- **Async functions.** The mock docs: "`create_autospec()` now returns an `AsyncMock` if the target is an async function" (since Python 3.8), and calling an `AsyncMock` gives an awaitable (https://docs.python.org/3/library/unittest.mock.html).
- **Overlap with AR-18.** The lookup-site rule and the autospec rule are shared with AR-18. This page stands on its own for the pytest parts: `monkeypatch` versus `mocker`, `raising`, fixture scope, the object versus string target and the pytest rules. If the Lead reads it as too close, merge the shared "where to patch" paragraph into AR-18 and keep the pytest section as AR-34's second half.

## What LLMGRAPH shows
LLMGRAPH does not list `monkeypatch.setattr` as a use of `charge`; the attribute name stays a string.

## References
pytest (fetched 2026-10-30):
- Monkeypatch how-to (`setattr`, `raising`, patch the reference your code uses, builtins, `context()`, undo): https://docs.pytest.org/en/stable/how-to/monkeypatch.html
- Reference, `MonkeyPatch` class (dotted-string target, `raising`, `context()`, `pytest.MonkeyPatch()`): https://docs.pytest.org/en/stable/reference/reference.html
- Fixtures how-to (fixture scope, requesting by name): https://docs.pytest.org/en/stable/how-to/fixtures.html

Python standard library (fetched 2026-10-30, carried over from AR-18; outside this work order's source list, listed for the quotes above):
- `unittest.mock`, where to patch, `autospec`, `create_autospec`: https://docs.python.org/3/library/unittest.mock.html

Ruff: PT008 https://docs.astral.sh/ruff/rules/pytest-patch-with-lambda/ · PT019 https://docs.astral.sh/ruff/rules/pytest-fixture-param-without-value/

General rule: [AR-18](https://beta.llmgraph.in/agent-readable/ar-18.md). Chimera: [CH-02](https://beta.llmgraph.in/agent-readable/ch-02.md).

## Changelog
- 2026-10-30: draft 1.
- 2026-10-30: docs checks resolved
- 2026-10-30: remaining checks closed (claims reduced to documented behaviour)

## Related

- [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)
- [CH-02: Why do my pytest tests pass for a reason I can't see?](https://beta.llmgraph.in/agent-readable/ch-02.md)

## Chimeras that use this rule

- [CH-02: Why do my pytest tests pass for a reason I can't see?](https://beta.llmgraph.in/agent-readable/ch-02.md)

Last verified 2026-10-30
