Have you ever watched a unit test praise a mock, then still open a real socket anyway? I have, and the traceback made a live patch look like a silent no-op. I kept a forty-eight hour field notebook instead of writing a tidy postmortem first. What I tried, what broke, and what I would repeat are more useful than a cleaned-up hero story.
This was not flaky Wi-Fi, and it was not a wrong base URL either. The helper had bound a function name at import time, and my patch never sat on that name. If you have only ever mocked requests.get because a tutorial did, this notebook is for you.
Hour 0–6: I blamed the HTTP library, then pytest, then my laptop
The production helper was tiny, which made the failure feel almost personal. A status fetch should not need a tracing session, a proxy diagram, or a war room. I still opened all three in my head before I opened __dict__.
# app/client.py
from requests import get
def fetch_status(url: str) -> int:
response = get(url, timeout=2)
return response.status_code
The first test looked like every snippet I have bookmarked on a tired evening. Does that resemblance already make you nervous, or is that only me after this week?
# tests/test_client.py
from unittest.mock import patch
from app.client import fetch_status
def test_status_is_mocked():
with patch("requests.get") as mocked:
mocked.return_value.status_code = 204
assert fetch_status("https://example.invalid") == 204
I ran it and waited for a quiet assertion with no network. Instead, requests tried DNS for a host that should never resolve. The mock object looked healthy in the debugger, which is a cruel kind of green.
What I tried in the first six hours, in order:
- Upgrading
requestsandpytest, because version folklore is a very comfortable rabbit hole. - Adding
pytest-httpx, then removing it when I remembered the stack does not usehttpx. - Exporting
NO_PROXY=*and poking at IPv6, which is how you know a day is sliding. - Printing
id(get)in the test and in the helper, then talking myself out of the mismatch.
The ids were different, and that should have been the whole story. I wrote “probably pytest import order” in the notebook and kept moving. That one sentence cost me the next night of sleep.
Hour 8–18: A clean interpreter still failed the same way
Was my laptop lying through a forgotten user-site plugin? That question is fair when a local run and a clean run usually disagree. I wanted a throwaway process with none of my shell aliases and none of last year’s PYTHONPATH experiments.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode’s free server option for that isolation, and free model access when I wanted a second pair of eyes on one patch line. That is not a hardware claim, and it is not a benchmark. It was simply an interpreter that did not inherit my laptop’s junk drawer.
python -m venv .venv
. .venv/bin/activate
pip install requests pytest
pytest tests/test_client.py -vv
Same explosion. Same requests.exceptions.ConnectionError. The laptop was innocent, which is a rare and honestly annoying outcome. Have you noticed how much slower debugging gets after you lose your favorite scapegoat?
I pasted the helper and the test into a free model and asked where the patch should point. It answered patch("requests.get"), because that is what almost every public snippet shows. Would you have trusted that answer after eighteen hours of noise? I almost did, again, which is why the notebook exists.
Hour 24–36: The name in the traceback was not the name I patched
Here is the Python rule I keep relearning the hard way, usually after midnight. from requests import get copies a function object into app.client.get at import time. The helper never looks up requests.get on a later call. It looks up the global get inside its own module dictionary.
unittest.mock.patch("requests.get") replaces an attribute on the requests module object. My helper does not read that attribute anymore. So a mock can be live, inspectable, and completely irrelevant to the call you care about.
Have you checked app.client.__dict__["get"] during a failing test, or do you only admire the mock object? I had been doing the second thing with great confidence.
import app.client
import requests
from unittest.mock import patch
def test_which_name_moved():
original = app.client.get
with patch("requests.get") as mocked:
assert app.client.get is original
assert requests.get is mocked
That test is the field note I wish I had written at hour two. The bound name stayed put. The library name moved. The production code used the bound name, so the socket was still fair game.
The artifact: import style versus patch target
I now keep a four-row table in the repo, next to the HTTP helpers. It is boring, printable, and faster than another all-nighter. If you only steal one thing from these notes, steal the table.
| Import inside the module under test | Name looked up at call time | Patch target that intercepts | Patch target that quietly does nothing |
| from requests import get | app.client.get | app.client.get | requests.get |
| import requests then requests.get(...) | app.client.requests.get | app.client.requests.get or requests.get | app.client.get, which does not exist |
| from requests import get as http_get | app.client.http_get | app.client.http_get | both requests.get and app.client.get |
| Test does from app.client import get before patching | a second bound copy in the test module | the name the call actually uses | patching a module you never call |
The corrected test is almost insultingly small after two days of theater.
from unittest.mock import patch
from app.client import fetch_status
def test_status_is_mocked_on_the_bound_name():
with patch("app.client.get") as mocked:
mocked.return_value.status_code = 204
assert fetch_status("https://example.invalid") == 204
mocked.assert_called_once_with("https://example.invalid", timeout=2)
If you want a mechanical check before you argue with pytest, parse the imports. The script below is incomplete on purpose: it prints candidate names, and you still have to read the call sites.
# tools/list_patch_candidates.py
from __future__ import annotations
import ast
import sys
from pathlib import Path
class ImportVisitor(ast.NodeVisitor):
def __init__(self) -> None:
self.rows: list[tuple[str, str, str]] = []
def visit_Import(self, node: ast.Import) -> None:
for alias in node.names:
bound = alias.asname or alias.name.split(".", 1)[0]
self.rows.append(("import", bound, alias.name))
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
if not node.module:
return
for alias in node.names:
bound = alias.asname or alias.name
origin = f"{node.module}.{alias.name}"
self.rows.append(("from-import", bound, origin))
self.generic_visit(node)
def main(source_path: str, dotted_module: str) -> None:
tree = ast.parse(Path(source_path).read_text(encoding="utf-8"))
visitor = ImportVisitor()
visitor.visit(tree)
print(f"{'style':<12} {'bound':<16} {'call-time name':<28} note")
for style, bound, origin in visitor.rows:
call_time = f"{dotted_module}.{bound}"
print(f"{style:<12} {bound:<16} {call_time:<28} patch this name")
if style == "import":
print(
f"{'':<12} {'':<16} {origin + '.get':<28} "
f"only if calls use {bound}.get"
)
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2])
Run it against the helper, not against the test file. The test file’s imports will lie about production binding, and you will patch a name the helper never reads.
python tools/list_patch_candidates.py app/client.py app.client
A forty-eight hour checklist I will actually keep
- Print the helper module dictionary for the function name before blaming CI.
- Reproduce once on a clean interpreter so user-site plugins cannot become the plot.
- Ask a model for patch targets, then distrust any answer that skips import style.
- Keep the decision table in the same pull request as the first HTTP helper.
- Assert
mock.assert_called_once_with(...), or the test can pass for the wrong reason.
Hour 40–48: What broke, and what I would repeat
What broke was not the mock library, and I wasted hours treating it like a suspect. patch did exactly what the documentation says: it replaces an object found by a dotted path. The path I handed it was a name my helper had already stopped using.
What broke was my habit of patching the public library name from memory. Tutorials often use import requests, and my helper used from requests import get. Those two lines are not interchangeable once a mock enters the room. Why do we keep pretending they are?
What I would repeat is the slow, unfashionable part. I would print module.__dict__, reproduce on a clean interpreter, and refuse a green test that never checks call arguments. I would also keep the AST helper, even though it cannot see dynamic imports.
What I would not repeat is package roulette. Upgrading libraries will not fix an identity comparison. Patching requests.get because the traceback mentioned requests is the same mistake with better lighting. Trusting a model that repeats the tutorial path is the tired version of that mistake.
A free model is a decent rubber duck when your eyes are sand. It is not a substitute for reading the module dictionary, and I will not claim otherwise. If you need that clean interpreter without dragging laptop configuration along, MonkeyCode’s free server option is the isolation step I actually used.
Limitations, and who should skip this workflow
This workflow is for Python code that calls a function through a bound global. It is the wrong tool for intercepting sockets, DNS, or TLS. If the bug is below the callable, a mock on a name will only hide the smoke.
Do not use unittest.mock.patch as your only HTTP guardrail in integration tests. A real adapter boundary, or a transport injected into the client, survives a refactor that renames get. Would you rather change one constructor in tests, or hunt dotted paths after every import cleanup?
Skip this approach if you already inject a callable into fetch_status. You do not need a dotted path if the test can pass lambda url, timeout: Dummy(204). Also skip it if your suite is not Python. The import-binding rule is a module-dictionary story, not a universal testing law.
AST listing will miss dynamic imports, importlib.import_module, and functions bound inside a closure. Treat the script as a checklist generator, not as a proof. It will not save you from patching the test module’s copy of a name you imported too early.
I am not recycling other failure modes here. Interpreter mismatch, parent pytest.ini files, wheel paths, and naive datetime.now() cuts were not this bug. This notebook is only about a name that moved before the call.
The honest ending is unglamorous, which is how most debugging endings should sound. After forty-eight hours I changed one string in patch(...), added one assertion on the call args, and the socket stopped opening. Would I still start with the library name tomorrow? Probably, unless these field notes stay next to the test.
Top comments (0)