DEV Community

Taylor Wang
Taylor Wang

Posted on

I Mocked fetch for 48 Hours. The Name Was Bound at Import.

Have you ever watched a unit test claim it never touched the network, then seen a real socket open anyway? I spent forty-eight hours on that exact mess last week, and the mock object looked perfect in the traceback. The live function still ran because the name I patched was not the name the call site used. This is a field log of what I tried, what broke, and what I would repeat.

I am writing this as a lab notebook, not as a war story from some unnamed production fleet. The package below is a minimal reproduction you can run locally. If a coding assistant keeps “fixing” your HTTP tests by wrapping broader exceptions, read the import graph before you trust the green check.

Hour 0–8: the test that looked offline

I started with a tiny client that should have been trivial to stub. The production-shaped module imported a helper and returned one field from the payload. The test patched the helper, asserted on the return value, and reported zero calls to the live function. Why did a later integration check still open a socket?

Here is the layout I used for the reproduction:

repro_bound_name/
  http_lib.py
  client.py
  test_client.py
Enter fullscreen mode Exit fullscreen mode
# http_lib.py
"""Stand-in for a network helper. Raises if anyone actually calls it."""

class LiveCallError(RuntimeError):
    pass


def fetch(url: str) -> dict:
    raise LiveCallError(f"live fetch reached for {url!r}")
Enter fullscreen mode Exit fullscreen mode
# client.py
from http_lib import fetch


def get_status(url: str) -> str:
    payload = fetch(url)
    return payload["status"]
Enter fullscreen mode Exit fullscreen mode

The first test looked like every snippet I have pasted from a chat window. I patched http_lib.fetch, which is where the function is defined, and I expected client.get_status to see the stub.

# test_client.py
import pytest
from unittest.mock import patch

from client import get_status
from http_lib import LiveCallError


def test_get_status_patches_the_definition():
    with patch("http_lib.fetch", return_value={"status": "ok"}) as mocked:
        assert get_status("https://example.invalid/status") == "ok"
        mocked.assert_called_once()
Enter fullscreen mode Exit fullscreen mode

Run that in isolation and watch the failure mode, not the traceback length.

python -m venv .venv
. .venv/bin/activate
pip install pytest
pytest -q test_client.py
Enter fullscreen mode Exit fullscreen mode

The test does not fail with an assertion on the mock. It fails because fetch inside client.py is still the live function, and the live function raises LiveCallError. Have you looked at client.__dict__ after import, or did you only inspect http_lib.fetch?

Hour 8–24: what I tried, and what broke

I did the usual thrash before I drew the import graph on paper. Each step felt reasonable for about twenty minutes, and each one left the live exception in place.

  1. I added autospec=True because a model suggested the mock signature was wrong. The call never reached the mock, so the spec did not matter.
  2. I switched to new_callable and an AsyncMock even though fetch is synchronous. That produced a coroutine warning on a different branch and solved nothing here.
  3. I moved the patch decorator onto the test class. Decorator placement does not rebind a name that was copied at import time.
  4. I inserted importlib.reload(client) after patching http_lib. Reload reruns from http_lib import fetch only if the patch is active during that reload, which my fixture did not guarantee.
  5. I blamed pytest import mode, then conftest.py, then the virtualenv. The traceback still pointed at http_lib.fetch, which was honest, and at a name client.py no longer looked up.

What broke the story I wanted to believe was a one-line probe I should have written at hour one.

import client
import http_lib

print(client.fetch is http_lib.fetch)  # True before the patch

with patch("http_lib.fetch", return_value={"status": "ok"}):
    print(client.fetch is http_lib.fetch)  # False while the patch is active
    print(client.fetch)  # still the live function object
Enter fullscreen mode Exit fullscreen mode

from http_lib import fetch copies a function object into client.fetch at import time. patch("http_lib.fetch") replaces the name on http_lib only. The call site in get_status never does a lookup on http_lib again. The mock can be perfect and still be a dead object sitting in the wrong namespace. Does that sound obvious after the fact? It was not obvious while a coding assistant kept offering new mock flags.

Hour 24–48: a clean interpreter, then the actual patch target

Around hour ten I copied the failing package onto a throwaway interpreter. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access and free server option so the experiment would not inherit my laptop's site-packages, pytest plugins, or leftover .env files. The free model was useful for generating the probe prints, and it was wrong when it told me to patch requests.get in a codebase that does not import requests.

The rule from unittest.mock is still the one that matters: patch the name as the code under test looks it up. For this layout, that name is client.fetch, not http_lib.fetch.

def test_get_status_patches_the_bound_name():
    with patch("client.fetch", return_value={"status": "ok"}) as mocked:
        assert get_status("https://example.invalid/status") == "ok"
        mocked.assert_called_once_with("https://example.invalid/status")
Enter fullscreen mode Exit fullscreen mode

If you prefer not to depend on a copied name, stop copying it. Look up the helper on the module every time.

# client_lookup.py
import http_lib


def get_status(url: str) -> str:
    payload = http_lib.fetch(url)
    return payload["status"]
Enter fullscreen mode Exit fullscreen mode
def test_get_status_patches_the_module_lookup():
    with patch("http_lib.fetch", return_value={"status": "ok"}) as mocked:
        from client_lookup import get_status

        assert get_status("https://example.invalid/status") == "ok"
        mocked.assert_called_once()
Enter fullscreen mode Exit fullscreen mode

Both styles are valid. Mixing them in one package is how you get a mock that reports zero calls while a socket opens in another thread. I would rather keep import module plus module.func() when several files share the helper, because then one patch target stays honest.

Artifact: a decision table I will keep next to pytest

I wrote this table on the second day and I will paste it into the next mock review. It is not complete for every library, but it would have saved me the first evening.

What the code under test does Name the call site looks up Patch target that actually intercepts What a green mock with zero calls usually means
from http_lib import fetch then fetch(url) client.fetch patch("client.fetch") You patched http_lib.fetch after the copy
import http_lib then http_lib.fetch(url) http_lib.fetch patch("http_lib.fetch") You patched a similarly named helper in another package
from client import get_status in the test, then patch before import depends on import order import after the patch, or patch the bound name The test imported client at collection time
decorator @patch("http_lib.fetch") on a test that imported client at top level client.fetch still client.fetch The decorator never rebound the copy
importlib.reload(client) while http_lib.fetch is patched client.fetch after reload works only if the patch is active during reload The reload ran outside the with patch(...) block

Commands I now run before I ask any model to “just stub the network”:

python -c "import client, http_lib; print(client.fetch, http_lib.fetch, client.fetch is http_lib.fetch)"
pytest -q test_client.py --tb=short
python -m pytest test_client.py::test_get_status_patches_the_bound_name -vv
Enter fullscreen mode Exit fullscreen mode

If those three disagree, stop adding mock kwargs. The namespace is lying to you, not the assertion helpers.

What I would repeat

I would repeat the identity probe before I repeat any mock recipe. Printing client.fetch is http_lib.fetch inside and outside the patch context is cheaper than another round of AsyncMock. I would also repeat the throwaway interpreter when the laptop already has pytest-httpx, responses, and a personal conftest.py that silently stubs things. Local plugins hide the live call, which makes the wrong patch look correct.

I would not repeat asking a model to invent the patch string from the function’s defining module. Defining module and lookup module are different questions. I would not repeat reload() as a testing strategy unless the test owns both the patch context and the import. I would not repeat copying from x import y across a package that needs surgical stubs, unless every test patches y on the consuming module.

A short checklist I will actually keep:

  • Draw the import arrows before you draw the mock.
  • Patch the name the call site uses, not the name the helper file defines.
  • Prefer import module plus attribute access when many files share one helper.
  • Import the code under test after the patch, or accept that collection-time imports freeze copies.
  • Treat zero calls on a mock as a failed experiment, not as proof the code is offline.

Limitations, and who should skip this

This workflow is for pure-Python helpers whose names you control. It does not replace recorded HTTP libraries when you must assert on headers, retries, or wire format. It does not help if the live call happens in a C extension you cannot rebind. It does not help if another thread imported the client before your patch, which is a different race.

Do not use a throwaway coding environment for secrets, production tokens, or dumps of customer traffic. Free model access will not read your import graph for you if you paste only the test file. If you need to stub a whole transport layer, use a dedicated test double at a boundary you own, instead of sprinkling patch strings until pytest goes green.

I also would not use this as a reason to mock every function in a unit test. Sometimes the cheaper fix is to pass fetch as an argument and skip unittest.mock entirely.

def get_status(url: str, fetch=fetch) -> str:
    payload = fetch(url)
    return payload["status"]
Enter fullscreen mode Exit fullscreen mode

That default still binds at import time, so tests should pass fetch= explicitly. Dependency injection is boring, and it would have ended this field log on hour two. Would I still start with patch next time, because the helper is already imported in twelve files? Yes, but I will patch the bound name, and I will print the identity check first.

If you keep field notes like these, a clean interpreter with a free coding model nearby is a decent second pair of eyes, not a substitute for reading the import graph.

Top comments (0)