Skip to content
← All articles

Why Constructor Injection Beats mock.patch

A suite built on unspec'd patches goes green after you delete the function under test. Where patching actually binds, why a bare Mock checks nothing, when patching is still right, and what autospec buys you.

Here is a test that passes. Delete the function it is testing and it still passes.

def test_charges_the_card(mocker):
    gateway = mocker.patch("billing.service.gateway")
    gateway.charge.return_value = {"status": "ok"}

    result = charge_customer("cus_1", 500)

    gateway.charge.assert_called_once_with("cus_1", 500)
    assert result["status"] == "ok"

Every assertion in it is about the mock. The return value came from the mock. The call record came from the mock. If someone renames charge to capture, the mock accepts gateway.charge(...) anyway, because a Mock accepts any attribute and any call. The test’s relationship to production code is that it once knew the name of a module.

This is how a codebase ends up with 94% coverage and no confidence, and it is worth being precise about the failure modes, because “mocks are bad” is not useful advice and is not true.

Failure mode 1: you must patch where the name is looked up

mock.patch replaces an attribute on a module object. Which module depends entirely on how the code under test spelled its import.

# billing/service.py
from billing.gateway import charge      # binds `charge` into billing.service NOW

Here billing.service.charge is a separate name from billing.gateway.charge. Patching billing.gateway.charge replaces the original, but billing.service already holds its own reference and never looks it up again. The patch does nothing. Your test passes — because a billing service that fails to charge still returns something, and your assertion was on the mock you configured.

Change the import to from billing import gateway and use gateway.charge(...), and now the attribute is looked up at call time, so patching billing.gateway.charge works and patching billing.service.charge fails with an AttributeError.

So the patch target is coupled to the import style of the module under test. Someone tidying imports — a change with no behavioural content whatsoever, the kind that gets approved in eleven seconds — silently un-patches a test somewhere else in the repo.

💡If a patch that silently stops applying is the danger, why doesn't the test just fail? The real function would run and hit the network. click to reveal

Sometimes it does, and those are the lucky cases. Often it does not, for three reasons.

First, the test frequently asserts on the mock rather than on behaviour. gateway.charge.assert_called_once_with(...) is checking the mock’s own call log. If the patch did not apply, the mock was never called by anything — but many suites never assert that, they assert result["status"] == "ok" where result came from a different, still-patched collaborator.

Second, other patches in the same test can mask it. A test that patches four things and loses one still has three fakes standing between it and reality, and the un-patched call often lands in a code path guarded by one of the others.

Third — and this is the one that bites in CI — the real call may succeed against something that is there but shouldn’t be: a localhost service left running by another test, a cached response, an environment variable pointing at a staging endpoint. Now the test passes for a reason that has nothing to do with the code.

The generalisation is the point. A patch is a silent mechanism: applying and not applying look identical from inside the test. Anything whose failure mode is indistinguishable from its success mode cannot be the foundation of a test suite.

Failure mode 2: Mock() agrees with everything

A plain Mock has no signature. m.charge(1, 2, 3, "garbage", nonsense=True) returns another Mock and records the call. m.chrage(...) — misspelled — does the same. assert_called_once_with on a misspelled assertion method is even better: m.assert_called_once() exists, m.assert_called_once_with(...) exists, and m.assert_called_onceish(...) returns a Mock, which is truthy, which passes.

In typing terms, MagicMock is Any. It is not that mypy is lenient about mocks; it is that a mock’s type is the one type that is compatible with everything, checked against nothing. Introducing one into a test is the same, for verification purposes, as deleting the annotations from that test.

Failure mode 3: patching is global

mock.patch mutates a module attribute for the duration of the patch. Used as a decorator or a context manager, that duration is well-defined. Used as monkeypatch.setattr inside a fixture, or via a raw mock.patch(...).start() without a matching stop(), it leaks into every subsequent test in the process — and the failure surfaces in whichever unrelated test happens to run next, which depends on collection order, which depends on filenames.

When you must patch, scope it explicitly:

with pytest.MonkeyPatch.context() as mp:
    mp.setenv("REGION", "eu-west-1")
    ...

And be aware that some things are not safe to patch at all. Patching builtins.open or compile breaks pytest itself, because pytest uses them while your test is running — assertion rewriting, output capture and traceback rendering all sit in the same process.

The alternative pytest’s own docs recommend

For code that you control, a safer long-term pattern is to make dependencies explicit so they can be passed into the code under test instead of patched globally.

That is the whole idea. The dependency becomes a parameter:

class Gateway(Protocol):
    def charge(self, customer: str, cents: int) -> Receipt: ...

def charge_customer(customer: str, cents: int, *, gateway: Gateway) -> Receipt:
    return gateway.charge(customer, cents)

and the test supplies a real object:

class FakeGateway:
    def __init__(self) -> None:
        self.calls: list[tuple[str, int]] = []

    def charge(self, customer: str, cents: int) -> Receipt:
        self.calls.append((customer, cents))
        return Receipt(id="rc_1", status="ok")

def test_charges_the_card() -> None:
    gateway = FakeGateway()
    receipt = charge_customer("cus_1", 500, gateway=gateway)
    assert receipt.status == "ok"
    assert gateway.calls == [("cus_1", 500)]

Nothing here can silently stop working. There is no name to look up, no module to patch, no import style to be sensitive to. And critically:

mypy checks FakeGateway against the same Gateway protocol as the real implementation. Rename charge to capture in the protocol and the type checker fails the fake and the real client together, in the same run, before anything executes. That is the entire argument, and it is an argument a MagicMock cannot participate in, because Any is compatible with every protocol that ever existed.

💡Injection means threading a parameter through every layer between the entry point and the thing that needs it. Isn't mock.patch genuinely less work? click to reveal

For one call site, yes. The comparison people make is patch versus injection for this test, and by that measure patching wins every time. It is the wrong comparison.

The costs of injection are paid once, at the seam, and they are visible: an extra parameter, usually with a sensible default, so production callers do not change. The costs of patching are paid repeatedly, invisibly, by people who did not write the test — the import-tidying commit that un-patches something, the ordering-dependent leak, the day a mock’s shape drifts from reality and nobody notices until an incident.

There is also a design signal hiding in the objection. If threading a dependency through is genuinely painful, that usually means the dependency is being reached for very deep in a call stack that had no business knowing about it. The “difficult to inject” feeling is a fairly reliable detector of a layering problem, and the patch is what lets you keep not fixing it.

The honest middle ground: default the parameter. def charge_customer(..., *, gateway: Gateway = default_gateway) -> Receipt costs production nothing and gives tests a seam. You have not adopted a framework; you have added one keyword argument.

When patching is the right tool

Three cases, and they share a shape: you do not own the seam.

  1. Third-party code you cannot change. You cannot add a parameter to someone else’s library, and wrapping it in an adapter is sometimes more machinery than the situation deserves.
  2. Legacy module-level globals. A module that reads a singleton at import time has no seam to inject into. Patching is the bridge while you build one.
  3. Environment variables and process state. monkeypatch.setenv and monkeypatch.chdir are the correct tools, precisely because the thing being replaced is global — the process’s environment. There is nothing to inject.

And when you do patch, use autospec=True or create_autospec, which builds the mock from the real object’s signature. The documentation’s phrasing is the reason: the mock then

fails in the same way as your production code if used incorrectly

Wrong argument count, wrong keyword name, an attribute that does not exist — all become errors. It does not recover the type-checking you gave up, and it does not fix the where-to-patch problem, but it converts the most common silent failure into a loud one. mock.patch without autospec should feel like a code smell.

The rule

Own the seam and you get a fake that a type checker verifies. Do not own it, patch it with autospec=True and a comment saying why. Everything in between is a test that will one day pass for the wrong reason, and by then nobody will remember which one it was.