Have you ever watched a unit test claim it mocked a client, then watched the real function fire anyway? I spent forty-eight hours on that mismatch, and the Mock object in my test file was never the real problem. The function under test had already bound a name, so I kept wrapping a module attribute it no longer read. These are field notes from that stretch: what I tried, what broke, and the harness I would run again tomorrow.
Hour 0 — the failure that looked like infrastructure
The first traceback did not mention unittest.mock at all, which is how I wasted an evening blaming runners. It looked like a network flake because the real client raised from inside a helper I did not write that week. Why would a so-called unit test touch that helper if the patch decorator had done its job? I reran the job, restarted the environment, and only then admitted the patch target never intercepted the call.
Local silence made it worse, because silence is not proof. Silence can be a different import graph, a second package named app, or a helper that was bound before your test module ran. I needed a harness that could fail in one process, without plugins, and without a live hostname.
Hour 6 — the tutorial-shaped patch
I reduced the production helper to three tiny modules so I could stop arguing with a full service tree. No HTTP, no extra packages, just a client that explodes if anyone actually calls it.
# app/client.py
def get(url: str, timeout: float = 2.0) -> dict:
raise RuntimeError(f"real client reached for {url!r}")
# app/via_module.py
from app import client
def fetch_status(url: str) -> str:
payload = client.get(url, timeout=2.0)
return payload["status"]
# app/via_from.py
from app.client import get
def fetch_status(url: str) -> str:
payload = get(url, timeout=2.0)
return payload["status"]
The test I wrote at hour six matched every snippet I had ever skimmed, and that should have been the warning. I patched the definition site because that is the string my hands reach for when I am tired.
# tests/test_hour6.py
from unittest.mock import patch
from app.via_from import fetch_status
@patch("app.client.get")
def test_this_does_not_intercept_the_from_import(mock_get):
mock_get.return_value = {"status": "ok"}
assert fetch_status("https://example.invalid/health") == "ok"
That test does not fail with a polite assertion. It raises RuntimeError: real client reached, which is the honest outcome. The from-import copied get into via_from.__globals__ at import time, so the function never walked back to app.client when it ran. Do you see how the decorator can look perfect while wrapping a name the call site will never load?
Hour 12 — a confident answer that quoted the docs shape
I pasted both call sites into a coding assistant and asked which string unittest.mock.patch needed. It answered app.client.get with the same calm tone the standard library examples use, because those examples start from import module and attribute access. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access as a second reader, then reran the candidate tests on its free server option so my laptop PYTHONPATH could not keep rescuing a second copy of app.
The remote run helped for one boring reason: it failed the same way. A clean interpreter is a cheap lie detector when your local site-packages have survived three virtualenvs and a leftover editable install. The model still named the wrong target until I pasted sorted(fetch_status.__globals__) and asked it to choose from that list instead of from tutorial memory. Would I skip the assistant next time? No. I would skip asking it to invent a patch string before I print the function globals.
Hour 18 — the inspection that should have been hour one
Here is the command sequence I now run before I type @patch. Use the same executable pytest will use, or you are just collecting a new lie.
python -c "import sys; print(sys.executable)"
python -c "import pytest, sys; print(pytest.__file__); print(sys.executable)"
# tools/show_bindings.py
from __future__ import annotations
import inspect
from app.via_from import fetch_status as fetch_bound
from app.via_module import fetch_status as fetch_attr
def dump(label: str, func) -> None:
names = sorted(func.__globals__)
print(f"[{label}]")
print(" module:", func.__module__)
print(" file:", inspect.getsourcefile(func))
print(" has client:", "client" in func.__globals__)
print(" has get:", "get" in func.__globals__)
print(" globals:", names)
if __name__ == "__main__":
dump("attribute lookup", fetch_attr)
dump("from-import binding", fetch_bound)
If get is in the globals and client is not, your patch target lives on the function's own module. If client is in the globals, the call site does client.get(...), and you can patch that attribute on the imported module object. Mixing those two sentences in one afternoon is exactly how you lose a day.
Hour 30 — a matrix I can rerun, not another opinion
I stopped debating the traceback and wrote a table the next reviewer can execute in one shot. This is the artifact I wish had existed at hour two, because it turns a folklore rule into pass or fail.
# tests/test_patch_targets.py
from __future__ import annotations
from unittest.mock import patch
import pytest
from app.via_from import fetch_status as fetch_bound
from app.via_module import fetch_status as fetch_attr
@pytest.mark.parametrize(
("func", "target", "works"),
[
(fetch_attr, "app.via_module.client.get", True),
(fetch_attr, "app.client.get", True),
(fetch_attr, "app.via_module.get", False),
(fetch_bound, "app.via_from.get", True),
(fetch_bound, "app.client.get", False),
],
)
def test_patch_where_the_name_is_looked_up(func, target, works):
fake = {"status": "ok"}
if works:
with patch(target, return_value=fake):
assert func("https://example.invalid/health") == "ok"
return
with patch(target, return_value=fake):
with pytest.raises(RuntimeError, match="real client reached"):
func("https://example.invalid/health")
def test_missing_name_is_an_attribute_error_not_a_pass():
fake = {"status": "ok"}
with pytest.raises(AttributeError):
with patch("app.via_from.client.get", return_value=fake):
fetch_bound("https://example.invalid/health")
The third case matters as much as the first two. A bad patch string is not always a sneaky real call. Sometimes patch explodes because the attribute does not exist on the module you named, and that explosion is easier to read than a quiet network hit. I still keep the RuntimeError in app.client.get so a wrong-but-importable target cannot pass by accident.
Decision table I keep next to the test
-
from app import clientthenclient.get(...): patchapp.via_module.client.getorapp.client.get. Both work, because lookup ofgethappens at call time on the shared module object. -
from app.client import getthenget(...): patchapp.via_from.get. Patchingapp.client.getwraps a name the function body will never load again. -
from app.client import getthen patchapp.via_from.client.get: you getAttributeErrorat patch time. That is not a green mock. That is a missing attribute. - Safe default when you are tired: patch the name in the module that owns the function, after you print
__globals__.
The surprising row is the first one. People memorize "never patch where it was defined," and then they are shocked when app.client.get works for attribute lookup. It works because via_module still holds the module object and asks it for get on every call. The from-import does not ask again. That is the whole bug.
What broke while I trusted the wrong string
The assistant was not the only thing that wasted hours. My own habits piled on top of the import binding, and each one looked reasonable in isolation.
- I compared a REPL session that did
import app.clientwith a test file that didfrom app.via_from import fetch_status. Those are not the same program. - I patched at module import in one file and later imported the already-bound function into another file. The second file never saw the patch.
- I trusted a green test on
via_moduleand copied the same decorator ontovia_from. The first module made the second look solved. - I almost added
requestsback into the harness "to make it realistic," which would have smuggled DNS back into a unit test.
Did any of those require a new framework? No. They required me to stop treating the patch string as a search keyword and start treating it as a runtime lookup.
What I would repeat in the first thirty minutes
I would not start with a bigger mock library. I would start with a client that raises, two import styles, and one matrix.
- Freeze the interpreter: print
sys.executablefrom the same command that will run pytest. - Dump
func.__globals__for every function I intend to patch, including helpers hiding behind a thin wrapper. - Write the parametrized matrix above before I write the "real" test that product people will read.
- Keep a raising stand-in at the true definition site so a wrong target cannot return accidental success.
- If I ask a model for candidate strings, I paste the globals list and the two import styles. I do not paste a tutorial and hope.
The rule I would write on a sticky note is short enough to survive a bad night. Patch the name where the running function looks it up, not the name you used in a different file last month.
Limitations, and who should not use this notebook
This workflow is for Python functions that look up names through ordinary module globals. It is not a universal mocking religion, and it will mislead you if you stretch it.
- Do not send proprietary service code to a remote model or a free server if your policy forbids it. The matrix above is small because it has to be.
- Do not use this as an excuse to skip reading the import lines. Free models still guess the docs shape, especially
import moduleplus attribute access. - Do not apply the same strings to C extensions, decorators that replace
__globals__, or code that rebindsgetafter import. - Do not replace socket-level testing with
patchwhen you actually need to prove TLS, DNS, or retry behavior. - Do not treat
app.client.getas always wrong. For attribute lookup through a module object, it is often right, and that exception is how this bug stays alive.
If your team cannot run pytest on a short local harness, a remote rerun will not save you. It can only tell you whether leftover path entries were part of the story.
The note I kept after hour 48
The mock never fired because the from-import had already bound get, and I kept wrapping a module the function no longer consulted. The fix was not a smarter decorator. The fix was printing the globals, encoding both import styles as tests, and refusing any patch string that could not pass that matrix. I still ask a second reader for candidate targets when the package tree gets noisy, but I do not let that reader skip the lookup.
Top comments (0)