skip to content

Why does assigning over a module attribute in a test leak into later tests?

level: juniorimportance: must knowfreq 62%

answer

  1. One module object per process
  2. Nothing rolls a plain assignment back
  3. A failing assert skips the last line
  4. Undo registered at the point of change
  5. patch's finally, or addCleanup

basics

~20 s

An imported module exists once per process, cached in sys.modules, so rebinding one of its attributes changes it for every user until something restores it. A plain assignment has no rollback, and a failing assertion skips the restore line.

solid answer

~50 s

Importing a module does not copy it. The first import runs the module body and stores the resulting module object in `sys.modules`; every later import of that name hands back the same object. So `lab_loader.parse = fake` is a `setattr` on a process-wide object, and it stays until code puts the original back. Two things then go wrong. The restore is usually the last line of the test body, so any failure, error or skip jumps over it. And the leak is invisible where it happens: a later test picks up the fake and either fails for no visible reason or passes when it should not, with both symptoms depending on test order. Make restoration structural instead: `unittest.mock.patch` used as a context manager or decorator restores in a `finally`, and `addCleanup` registers the undo at the moment you make the change.

code

python · 28 lines
python
import statistics
from unittest.mock import patch

original = statistics.mean


def fake_mean(values):
    return 0.0


def hand_rolled_test():
    statistics.mean = fake_mean
    assert statistics.mean([1, 3]) == 99.0
    statistics.mean = original


def patched_test():
    with patch.object(statistics, "mean", fake_mean):
        assert statistics.mean([1, 3]) == 99.0


for test in (hand_rolled_test, patched_test):
    statistics.mean = original
    try:
        test()
    except AssertionError:
        pass
    print(test.__name__, "left statistics.mean at", statistics.mean([1, 3]))

go deeper

for a junior

Be ready to say that an import hands you the one shared module object rather than a copy, and that anything you assign onto it stays until you put it back. Know the with-block form of patching as your default.

for a middle

Explain the mechanics out loud: the module lives in sys.modules, the patch is a setattr on its namespace, and the undo has to sit in a finally or a registered cleanup rather than the last line of the test body.

for a senior

Show how you diagnose the symptom in a real suite — a test that passes alone and fails in company — and how you keep the class of bug out for good: no bare assignments to shared objects, restoration registered at the point of change.

for a principal

Own the policy: whether the suite runs in a randomized order, whether leaks are detected rather than tolerated, and when repeated patching is telling you the code needs an injection point instead of a stricter test convention.

### One module object, one namespace When Python executes `import lab_loader`, it first looks the name up in `sys.modules` and returns immediately if it is there. Only on a miss does it find the source, execute the module body in a fresh namespace, and store the resulting module object under that name. The consequence that matters for tests is the lookup: a module object is created once per interpreter and shared by everyone who imports it. A module's global namespace *is* that object's attribute dictionary, so every module-level name is an attribute of a shared object, and `lab_loader.parse = fake` is an ordinary `setattr` on it. That is what makes monkeypatching so easy in Python — there is no visibility rule to defeat — and it is also why the change reaches so much further than the test that made it. Nothing about the assignment is scoped to the function, the class, the file or the runner. It holds until some code assigns the original back. ### The two failure shapes **The restore that never runs.** The natural hand-rolled shape is save, replace, exercise, restore, with the restore as the last statement and the assertion immediately above it. A failing assertion raises, the restore is skipped, and the fake is installed for the remainder of the process — the single failure you were about to investigate has become a suite-wide problem. An early `return`, a skip, or an unexpected exception inside the code under test all jump over it the same way. The one path you most need protection on is the one a trailing assignment does not cover. **The leak nobody attributes to the leaker.** The test that made the mess usually still reports its own honest result. What you see is a *different* test failing, often in another file, sometimes only when the whole suite runs. Worse, a leaked fake can make a later test pass that should have failed, which is a silent hole in the suite rather than a noisy one. Both symptoms depend on execution order, so they appear and disappear as tests are added, renamed or run in parallel across processes. ### Restoration has to be structural A `try/finally` around the body is the minimum honest version: the restore executes on every exit path. `unittest.mock.patch`, used as a context manager or a decorator, is the same idea packaged — it records the current attribute on entry and puts it back on exit inside a `finally`, whether the block completed or raised, and it also handles the awkward case where the attribute did not exist before and must be deleted rather than reassigned. `unittest.TestCase.addCleanup` attacks the problem from the other end: you register the undo at the instant you make the change, and the framework runs every registered cleanup after the test regardless of outcome — including the cleanups already registered when a later statement in `setUp` blows up, which is exactly where `tearDown` lets you down. `contextlib.ExitStack` covers the case where the number of patches is decided at runtime. ```python import lab_loader from unittest import TestCase class LoaderTest(TestCase): def setUp(self): original = lab_loader.parse self.addCleanup(setattr, lab_loader, "parse", original) lab_loader.parse = lambda row: {"id": row} ``` ### Rebinding is not the only way to change shared state If the module-level object is mutable — a configuration dict, a registry list, a cache — the damage is done by mutation rather than assignment, and saving the object before the test restores nothing, because the thing you saved is the thing that was mutated. You have to snapshot the *contents*: copy them, and on cleanup clear the live object and refill it from the copy. `unittest.mock.patch.dict` does exactly that for a mapping. Class objects are shared on identical terms. `LabParser.parse = fake` is visible to every instance in the process, including instances created before the assignment, and it has its own restore trap: if the attribute was inherited rather than defined on that class, reading it with `getattr` and writing it back copies the parent's attribute onto the subclass, which is a change, not a restoration. Deleting your patched attribute is the correct undo in that case — and it is one more reason to let a tool that knows the difference perform the undo. ### What the leak is telling you A test that reaches into a module and rewires a global is a test operating on state it does not own. Sometimes that is unavoidable — the module is third-party, or the value is a genuine process-level knob. Often it is a signal that the code under test resolves its collaborators itself instead of receiving them, and the cheapest long-run fix is to give the code a parameter rather than giving the suite a stricter patching convention. Either way, the rule to state in an interview is simple: never change shared state without registering the undo in the same breath.

  • Why does addCleanup protect you better than restoring on the last line of the test?
    `addCleanup` is registered the moment you make the change, and the framework runs the registered cleanups after the test whether it passed, failed, errored or was skipped — including the ones already registered when a later line of `setUp` raises. A restore written as the final statement of the test body only runs when everything above it succeeded, which is precisely the case that needs no protection.
  • The module global you need to change is a mutable dict rather than a name you rebind. What changes?
    Saving the object and reassigning it later restores nothing, because the code mutated the very dict you handed back. You have to snapshot the contents: copy them, then on cleanup clear the live dict and update it from the copy. `unittest.mock.patch.dict` packages that sequence, and the same reasoning applies to a module-level list, set or cache object.
  • Does the same hazard apply to patching an attribute on a class instead of a module?
    Yes — a class object is just as shared as a module object, so the fake is visible to every instance in the process, including ones created before the assignment. Restoration has an extra trap: if the attribute was inherited rather than defined on that class, writing back the value you read copies the parent's attribute down onto the subclass. Deleting the patched attribute is the correct undo there.

Rebinding a module attribute is writing on the office's one shared whiteboard: everyone who walks in afterwards reads your note, and if you leave the room in a hurry nobody wipes it.

saying these in an interview costs you the question

  • Thinks each test file imports its own fresh copy of a module
  • Assumes the assignment is undone when the test returns
  • Puts the restore on the last line and calls it safe
  • Believes importing the module again resets its attributes
  • Thinks only tests in the same file can be affected
  • Restores a mutated dict by reassigning the same object

context