Python developer reviewing mock patch targets and unit test results at a workstation.

Your Python Patch Exists. Did the Code Use It?

Thu, Oct 1, 2026

In Python unit tests, it’s possible for a mock patch to be installed without actually affecting the code under test. Imagine you write a test that patches fixture_pkg.transport.send, and your test’s assertion on the returned status passes, but the mock send was never actually called; the original (harmless) send function ran instead. In this situation, the test’s output is correct, but the dependency was not isolated. To catch this, we need a three-part proof in every test:

•    Actual lookup binding: identify which function object the code under test actually calls.

•    Mock invocation: assert that our mock was called exactly as expected (with correct arguments).

•    Original not invoked: assert that the original (sentinel) function was not invoked at all (no side effects).

Only when all three are satisfied can we trust the test. A passing result alone only shows some value was returned; it does not replace a mock-call assertion or guarantee our patch was used or the intended side effect was triggered.

Define What the Test Double Must Intercept

Consider two consumer functions that send an order via fixture_pkg.transport.send(order_id, *, dry_run=False). The first, consumer_early, does

from fixture_pkg.transport import send
def process_order(order_id):
    return send(order_id, dry_run=True)

The second, consumer_module, does

from fixture_pkg import transport
def process_order(order_id):
    return transport.send(order_id, dry_run=True)

In either case, the desired behavior is to return {'status': 'accepted'}. A test’s correctness criteria must include both output and dependency isolation. Concretely, an acceptable test must check:

•    Output correctness: the returned status is {'status': 'accepted'} (as our send function is defined).

•    Dependency isolation: verify the mock was called exactly once with the expected arguments, while the original send function made zero calls.

For example, using unittest.mock, one would write assertions like:

mock_send.assert_called_once_with(order_id, dry_run=True)
self.assertEqual(actual, {'status': 'accepted'})

Also check fixture_pkg.transport.call_records == [] to confirm the original sentinel saw no calls. The method assert_called_once_with ensures exactly one call with the given arguments (it also fails if call count ≠ 1), providing the necessary confidence in the dependency.

Importantly, we must patch the name as looked up by the consumer code, not just where it’s defined. For consumer_early, that lookup happened at module import time (it bound send directly to the original function), whereas for consumer_module, the lookup happens at call time via transport.send. Thus the test must target the appropriate name in each namespace. We state these acceptance assertions before installing the patch and writing the mock.

Pin Python and the Import Environment

To make our examples reproducible, we record the exact interpreter and environment. Suppose we run on CPython 3.10.10 (for illustration) on Linux. For example:

$ python3 --version        # CPython 3.10.10
$ uname -a                # Linux hostname 6.1.21 ...
$ pwd                     # /home/dev/tests
$ which python3           # /usr/bin/python3.10

With the working directory /home/dev/tests, our sys.path might start with '' (the current directory). We create a disposable package fixture_pkg/ in this directory. The absolute paths of our modules are like /home/dev/tests/fixture_pkg/transport.py, /home/dev/tests/consumer_early.py, etc.

We always run each test scenario in a fresh Python process to avoid leftover imports or side-effects. Remember that Python caches imports in sys.modules; by using separate processes (or explicitly clearing modules) we ensure each run starts with no modules loaded (except builtins). This isolates import order: e.g., if the module is already in sys.modules, a new import won’t re-execute it. By using subprocesses (or invoking python -c "..."), we control exactly when each module is loaded and patched.

Name Binding Timeline

To understand the binding, consider this sequence (each step is an import/event):

•    import fixture_pkg.transport: loads fixture_pkg/transport.py, creating the module object and its send function. The function is bound to fixture_pkg.transport.send.

•    import consumer_early: executes its code. The statement from fixture_pkg.transport import send binds the name send in consumer_early to the existing function object loaded in step 1. At this moment, consumer_early.send and fixture_pkg.transport.send refer to the same original function object.

•    Apply patch. For example, with patch('fixture_pkg.transport.send', ...) will replace the name send in the fixture_pkg.transport module’s namespace with a mock object. Importantly, this does not retroactively change the name bound in consumer_early.

•    Call consumer_early.process_order(...). It looks up send in its own namespace (step 2) and calls the original function (since its send was never updated).

•    Exit the with patch context, restoring fixture_pkg.transport.send to the original function.

This timeline shows why patching before or after import matters. Patching where an object is defined (e.g. fixture_pkg.transport) has no effect on a caller that already imported it into its own namespace. Conversely, for consumer_module, the lookup transport.send happens at call time, so patching fixture_pkg.transport.send will affect it (since it still refers to fixture_pkg.transport).

Build a Harmless Dependency Sentinel

We create a simple local package fixture_pkg to act as the external dependency. It contains one module with our sentinel function:

# fixture_pkg/transport.py
call_records = []

def send(order_id, *, dry_run=False):
    """Harmless transport.send: record the call and return accepted."""
    call_records.append((order_id, dry_run))
    return {'status': 'accepted'}

•    Behavior: send appends (order_id, dry_run) to the global list call_records and returns {'status': 'accepted'}. It does not contact any real service or perform I/O.

•    Usage: In tests, we can clear call_records between runs and inspect it to see if the original send was invoked.

Next, the consumer modules:

# consumer_early.py
from fixture_pkg.transport import send

def process_order(order_id):
    # Calls the (bound) send function immediately at import time
    return send(order_id, dry_run=True)

# consumer_module.py
from fixture_pkg import transport

def process_order(order_id):
    # Calls transport.send via module attribute at call time
    return transport.send(order_id, dry_run=True)

Here, consumer_early.process_order uses the name send imported into its namespace when the module was loaded; consumer_module.process_order looks up send through the transport module each time it runs.

We will write unit tests (using unittest.mock.patch) in separate Python processes. Each test will follow the acceptance criteria: check the return value, assert mock calls, and ensure fixture_pkg.transport.call_records stays empty (original not called). For example, a test might look like:

with patch(
    'fixture_pkg.consumer_early.send',
    autospec=True,
    return_value={'status':'accepted'},
) as mock_send:
    result = consumer_early.process_order(42)
    assert result == {'status':'accepted'}
    mock_send.assert_called_once_with(42, dry_run=True)
    assert fixture_pkg.transport.call_records == []

The above (correct) patch ensures consumer_early.send is replaced.

We avoid any external libraries or network. All code resides in a disposable directory we control. We explicitly mention the CPython build and path in test logs to anchor these examples to a concrete environment.

Create a Passing Output Assertion with the Wrong Patch

Now consider the negative case: the test patches fixture_pkg.transport.send instead of the name used by the consumer. For example:

import consumer_early
from unittest.mock import patch

# WRONG patch target: patches transport.send, not consumer_early.send
with patch(
    'fixture_pkg.transport.send',
    autospec=True,
    return_value={'status': 'accepted'},
) as mock_send:
    result = consumer_early.process_order(99)
    assert result == {'status': 'accepted'}  # This passes!
    assert mock_send.call_count == 1        # This will fail!

Although the returned status is correct (test “passes” the output assertion), the patch was never used: consumer_early had already bound the original send. So mock_send.call_count is 0 and fixture_pkg.transport.call_records is [(99, True)] (one call to the original). The test’s second assertion fails as intended, revealing the problem.

Concretely, in this scenario:

•    Actual binding: consumer_early.process_order looked up send in its own module (bound at import), so it called the original function, not the mock.

•    Mock received no call: mock_send.call_count == 0.

•    Original ran once: fixture_pkg.transport.call_records == [(99, True)].

This pattern explains a “false green” test: a naive test that only checked the return value would wrongly succeed. To catch it, we needed the extra assertion on mock_send (or on call_records). The Python docs warn exactly of this: you must patch where the object is looked up, not necessarily where it’s defined. In this negative test, patching the definition site was ineffective because the consumer had already captured the reference.

A failing test for the intended reason will explicitly assert the mock was called (or original was not called). In our example, the failure (call_count == 1 not true) clearly signals “patch did not intercept.” We can include such a negative test in our suite to ensure we catch misbound patches. This helps decide whether to retarget the patch rather than accept the test as-is.

Correct the Test: Positive Case

To fix the test for consumer_early, we patch the name it actually uses:

with patch(
    'consumer_early.send',
    autospec=True,
    return_value={'status': 'accepted'},
) as mock_send:
    result = consumer_early.process_order(99)
    assert result == {'status': 'accepted'}
    mock_send.assert_called_once_with(99, dry_run=True)
    assert fixture_pkg.transport.call_records == []

Now the mock replaces consumer_early.send. Running process_order(99) invokes the mock, so mock_send is called once with (99, dry_run=True), and the original send is never called (its call_records stays empty). The assertions all pass, confirming the dependency was correctly isolated.

Comparing with consumer_module, if we had instead:

with patch(
    'fixture_pkg.transport.send',
    autospec=True,
    return_value={'status': 'accepted'},
) as mock_send:
    result = consumer_module.process_order(123)
    ...

This would work, because consumer_module looks up transport.send at call time, so patching fixture_pkg.transport.send changes what it invokes. In that case, mock_send would see one call. But the key point is: always target the name as looked up by the code under test.

Thus, our test suite will include:

•    A negative test showing the wrong patch (as above) and confirming it fails on the missing mock call.

•    A positive test with the correct patch usage (as above), confirming the mock intercepts and original stays idle.

This guarantees the test harness truly exercises the intended boundary.

Dependency Captured as a Default Argument

A common pitfall is using a default argument to “inject” dependencies. For example:

# consumer_default.py
from fixture_pkg import transport

def process_with_default(order_id, sender=transport.send):
    # 'sender' is bound at function definition time!
    return sender(order_id, dry_run=True)

Here, the default sender was evaluated once, when the function was defined, and bound to whatever transport.send was at that moment. Patching fixture_pkg.transport.send later will not change the already-bound default. For instance:

from consumer_default import process_with_default
with patch(
    'fixture_pkg.transport.send',
    return_value={'status':'accepted'},
) as mock_send:
    result = process_with_default(7)

Even though we patched fixture_pkg.transport.send, process_with_default still calls the original function (because its sender default is the original). The mock is never used (and in fact autospec=True would raise no error since the bound function had the same signature). We’d see the sentinel’s call_records updated. This demonstrates that rebinding the module attribute doesn’t replace the default.

The Python docs explicitly state default expressions are evaluated when the function is defined. To avoid this trap, one can use explicit dependency injection or a None default:

def process_injected(order_id, sender=None):
    if sender is None:
        sender = transport.send
    return sender(order_id, dry_run=True)

Here, if sender=None, we look up transport.send at call time. Patching fixture_pkg.transport.send now works even for defaults, because we fetch transport.send inside the function. Alternatively, always pass the dependency in explicitly in tests.

Note: This is not a mutable-default issue but a binding-time issue. The fix is to design the function so that it looks up transport.send when called, or to allow injection. The test suite should cover the variant to show the binding difference.

Autospec and Wrong Call Signatures

When using patch(..., autospec=True), the mock will enforce the original function’s signature. For example, transport.send(order_id, *, dry_run=False) requires the keyword dry_run. If we call it incorrectly, autospec will raise a TypeError. Consider:

# In the test, intentionally call without the keyword
with patch('consumer_module.transport.send', autospec=True) as mock_send:
    mock_send.return_value = {'status': 'accepted'}
    # This call is missing the 'dry_run' keyword:
    result = consumer_module.process_order(55)  # Missing dry_run argument

Without autospec, the mock would happily accept wrong arguments. With autospec, as soon as process_order tries to call send(55) (omitting dry_run), Python raises TypeError: send() missing 1 required keyword-only argument: 'dry_run'. This signals that our code (or test) is calling the function incorrectly. However, autospec cannot detect that we patched the wrong function; it only checks signatures on the target we gave it. In other words, autospec ensures the replacement has the same signature, but it won’t warn if we patched a function that was never used by the consumer code. That’s why we still need the binding proof above. Autospec catches call-shape errors, but not namespace errors.

Binding Map and Decision Matrix

Putting it all together, we map each name to the actual function object at key points:

•    Initially, fixture_pkg.transport.send points to the original function object S.

•    After import consumer_early, consumer_early.send also points to S.

•    After import consumer_module, consumer_module.transport points to module fixture_pkg.transport, so consumer_module.transport.send also points to S.

We then apply patches:

•    Wrong patch (patch('fixture_pkg.transport.send')): Replaces fixture_pkg.transport.send with mock M; but consumer_early.send is still S (unchanged).

•    Correct patch (consumer_early) (patch('consumer_early.send')): Replaces consumer_early.send (which was S) with mock M; fixture_pkg.transport.send remains S (unpatched in that context).

•    Correct patch (consumer_module) (patch('fixture_pkg.transport.send') while testing consumer_module): Here, consumer_module.transport.send is looked up fresh, so it sees M instead of S.

The decision matrix summarizing outcomes:

Consumer Case

Patch Target

Mock Calls?

Original Calls?

Decision

consumer_early (wrong)

patch(
'fixture_pkg.transport.send'
)

0

1

Retarget patch: patch consumer_early.send

consumer_early (correct)

patch(
'consumer_early.send'
)

1

0

Accept test; correct isolation

consumer_module (patch)

patch(
'fixture_pkg.transport.send'
)

1

0

Accept for this case (lookup at call time)

Default-binding variant

patch(
'fixture_pkg.transport.send'
)

0

1

Refactor function (avoid bound default)

Fallback

None or incomplete checks

(unknown)

(unknown)

Hold test; insufficient coverage

Each scenario must satisfy all proof points. If a test fails on mock count or original calls, we either retarget the patch (as above), refactor the code to allow injection, or hold accepting the coverage claim until the test is fixed. A test that checks only output but not mock calls should be treated as incomplete.

Conclusion

A passing unit test means little unless it truly exercises the intended dependency boundary. By tracing exactly which function object is called (the binding map) and requiring both a mock call and no sentinel call, we verify that “our patch exists and was used.” We demonstrated that a naive test can return the correct value while still calling the original function (a false positive). The playbook above shows how to set up such tests correctly, using unittest.mock.patch with autospec, fresh Python processes to control imports, and binding-aware patch targets.

Key takeaway: Always patch the name as looked up by the code under test and include assertions for the mock call count and the absence of real calls. That way, a green test truly means the dependency was intercepted.

Try It Yourself

This kind of rigorous testing and debugging of mocks is precisely the skill developed in Refonte Learning’s QA Automation Engineering program. In a 3-month, 12–14 hours/week curriculum, students master automated testing frameworks (like Python’s unittest and unittest.mock), CI/CD tools (e.g. Jenkins), and real-world QA practices (Selenium, performance/security testing, etc.). Mentors guide learners through hands-on labs and capstone projects, preparing them for roles in test development. Dive in, practice with real code and testing tools, and build confidence in your tests. That’s the path to strong, trustworthy QA skills.