Isolation & Contracts

Replacing Singletons and Module Globals in Tests

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

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:

Python
# 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:

Python
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:

Python
# 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
Python
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.

Three steps away from a module-level singleton A vertical progression: an eagerly constructed module global, then a lazily cached accessor, then an accessor with an explicit override seam, and finally full injection where only the entry point resolves the dependency. Three steps away from a module-level singleton module-level object built at import, untestable Every importer copies the reference cached accessor built on first use, clearable cache_clear() gives isolation accessor + override substitutable without patching set_x(None) restores normal behaviour injected dependency only the entry point resolves it The global disappears
Each step is independently shippable and leaves production behaviour unchanged, which is what makes the migration reviewable.

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.

Four ways to deal with a global in tests A table comparing patching the module attribute, clearing a cache, an explicit override seam, and full dependency injection, across whether from-imports are handled, whether isolation is automatic, and the production change required. Four ways to deal with a global in tests Criterion Handles from-imports Production change patch the attribute no none cache_clear() the accessor yes call sites explicit override seam yes one function full injection yes constructor wiring
Patching is the only approach with no production change, and the only one that fails when another module copied the reference.

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:

Python
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.

Python
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.

Bash
# 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.

Three kinds of module-level state A table of three kinds of module-level state - a constructed client, a mutable container, and a configuration value - with how each leaks between tests and the conversion that fixes it. Three kinds of module-level state Criterion Leaks by Conversion constructed object accumulated state lazy accessor mutable container appended entries fixture-owned instance config constant read once at import read through a function registered callbacks import-time side effects explicit registration
All four are the same bug — state whose lifetime is the process rather than the test — and all four have a one-step conversion.

Edge cases and failure modes

  • functools.cache on a function with arguments caches per argument tuple, so an accessor that takes a config key needs cache_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 global assignment 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.attribute is not a restore. Deleting a patched attribute leaves the module without the name, so a later import fails with AttributeError rather 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=prepend interactions can import a module twice under different names, producing two independent globals. importlib mode 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.

← Back to Dependency Injection for Testability