client = ApiClient(settings.URL) at module scope is the most common untestable line in a Python codebase. It runs at import, before any test can intervene; it is copied into every module that does from services import client; and it holds state that survives the test that dirtied it. Patching it works about half the time, and the other half produces a test that passes while exercising the real object.
Prerequisites
- pytest 7.0+ for
monkeypatch, and Python 3.9+ forfunctools.cache. - The binding rules from where to patch and the seams from dependency injection for testability.
Solution
Three refactors, in increasing order of investment. All three can be applied to an existing codebase without touching call sites.
1. Lazy accessor. Replace the eager module-level object with a cached function. Import cost disappears, and there is now exactly one place where the object comes into existence:
# services.py — before
client = ApiClient(settings.URL) # built at import, untestable
# services.py — after
from functools import cache
@cache
def get_client() -> ApiClient:
return ApiClient(settings.URL) # built on first use, once
Call sites change from client.fetch() to get_client().fetch(), which is a mechanical edit and the only change production code sees. Tests can now clear the cache:
import pytest
import services
@pytest.fixture(autouse=True)
def _reset_client_cache():
services.get_client.cache_clear() # start clean
yield
services.get_client.cache_clear() # leave clean
2. Explicit override seam. A cache clear gives isolation but not substitution. Adding a settable override makes the double injectable without patching:
# services.py
_override: ApiClient | None = None
def set_client(client: ApiClient | None) -> None:
# Test seam: install a double, or pass None to restore normal behaviour.
global _override
_override = client
def get_client() -> ApiClient:
if _override is not None:
return _override
return _build_client() # cached construction as above
from unittest.mock import create_autospec
import pytest, services
from services import ApiClient
@pytest.fixture
def fake_client():
double = create_autospec(ApiClient, instance=True)
services.set_client(double)
yield double
services.set_client(None) # always restored, even on failure
3. Full injection. The end state is that nothing reaches for the global at all: the object is passed to the code that needs it, and only the application entry point calls get_client(). That is the destination, but the two steps above deliver most of the testability immediately and can ship independently.
Why this works
A module-level assignment binds an object into the module's namespace at import time, and every from module import name copies that binding into another namespace. Patching the definition site rebinds one name; the copies keep pointing at the original object, which is why patching global state works only when nothing has imported it by value. A function call, by contrast, is resolved at call time through the module object, so replacing what the function returns affects every caller regardless of how they imported it. That is the entire mechanism behind the accessor pattern: it converts a name lookup performed once at import into a name lookup performed on every use.
Guarding against leaked state
Whichever approach is used, the risk is the same: a test that installs a double and does not remove it. The failure lands in an unrelated test later in the session, and under pytest-xdist it may land on a different worker in a different file.
A single autouse fixture turns that class of bug into an immediate, attributable failure:
import pytest
import services
@pytest.fixture(autouse=True)
def _no_leaked_client(request):
yield
if services._override is not None:
pytest.fail(f"{request.node.nodeid} left a client override installed")
The check is cheap, runs everywhere, and names the offending test rather than the victim. The same shape works for any global registry: assert at teardown that the registry is the size it was at setup.
For globals you do not own — a third-party library's module-level configuration, a logging handler list, an os.environ key — prefer monkeypatch over manual assignment, because it restores automatically even when the test raises. The manual form is only justified when the restore logic is more complicated than an assignment, and then it belongs in a fixture with a try/finally, as described in patching builtins and sys.modules safely.
Testing the singleton itself
One case deserves its own treatment: when the singleton is the thing under test — a connection pool, a metrics registry, a feature-flag cache. Here substitution misses the point, and the test needs a real instance with a controlled lifetime.
import pytest
from myapp.pool import ConnectionPool
@pytest.fixture
def pool():
# A fresh instance per test, bypassing the module-level accessor entirely.
pool = ConnectionPool(size=2, dsn="postgresql:///test")
yield pool
pool.close()
def test_pool_reuses_connections(pool):
a = pool.acquire()
pool.release(a)
b = pool.acquire()
assert a is b # the reuse contract, tested on a real pool
The pattern is the same idea from the other direction: make construction explicit so the test controls the lifetime. A class that can only exist as a module-level singleton is a class that cannot be tested this way, and that constraint — not the global itself — is usually the real design problem.
Finding every global before you start
The migration is only safe if you know what you are migrating. Three searches find the bindings that matter, and the third is the one people skip.
# 1. Module-level construction: an assignment at column zero calling something.
$ grep -rnE '^[a-z_]+ = [A-Z][A-Za-z]*\(' src/ | grep -v 'def \|class '
# 2. from-imports that copy a binding into another namespace.
$ grep -rn 'from myapp.services import' src/ | grep -v 'import services$'
# 3. Mutable module-level containers — the ones nobody thinks of as singletons.
$ grep -rnE '^[A-Z_]+ = (\[\]|\{\}|set\(\))' src/
The third search finds registries, caches and accumulators declared as module constants. They look immutable because the name is uppercase, and they are the most common source of cross-test leakage: a handler list appended to at import time by three modules, a memo dict that grows across the session, a set of registered names that makes the second test see the first one's registration.
Once the list exists, rank it by how many tests currently patch each entry. A global that five tests already patch is the one to convert first — the conversion deletes five patches and their attendant fragility, which makes the change easy to justify in review.
Edge cases and failure modes
functools.cacheon a function with arguments caches per argument tuple, so an accessor that takes a config key needscache_clear()between tests or the first test's value persists for the second's key set.- Thread-safety of the override is not free. A
globalassignment is atomic enough for tests, but a production code path reading it concurrently with a test writing it — in a threaded test suite — is a race; keep overrides out of production execution paths. del module.attributeis not a restore. Deleting a patched attribute leaves the module without the name, so a later import fails withAttributeErrorrather than reverting to the original.- Import-time side effects survive
cache_clear(). If constructing the object also registers a signal handler or starts a thread, clearing the cache creates a second one; make construction idempotent or tear it down explicitly. - Doctest and
--import-mode=prependinteractions can import a module twice under different names, producing two independent globals.importlibmode avoids this, as noted in pytest configuration best practices. - A frozen dataclass singleton is not immutable state. Its fields may hold mutable containers, so a test that appends to one leaks exactly like any other global.
Frequently Asked Questions
Why does patching a module global work in one test and not another?
Because the global was already read and bound elsewhere. If another module did from settings import CLIENT at import time, that module holds its own reference, and patching the definition site leaves it untouched.
Is resetting a singleton between tests good enough? It works, but it is order-dependent by construction: the reset must run for every test, in the right place, forever. A lazy accessor with an injectable override removes the requirement instead of managing it.
How do I test a module-level client created at import time? Convert the eager construction into a cached accessor function. The import then costs nothing, tests can override the cache, and production behaviour is unchanged after the first call.
Does monkeypatch undo a singleton reset? It undoes the attribute assignment it made, not any state the singleton accumulated. If the object mutated internally, restore that state explicitly in the fixture teardown or rebuild the object.
Related
- Dependency injection for testability — the destination this migration is heading towards.
- Where to patch: understanding mock.patch targets — why patching a global works only sometimes.
- Patching builtins and sys.modules safely — restoring process-wide state without leaks.
- Injecting fakes vs mocks in constructors — what to pass once the seam exists.
← Back to Dependency Injection for Testability