Summary
In unit testing with Python’s unittest.mock, capturing the return value of a method requires returning the value from the mock to the caller (the code under test), while also intercepting the call for assertions. The confusion with wraps often stems from expecting the wrapper function to automatically capture the return value of the mocked method, whereas wraps actually delegates execution to a user-defined function. To capture the return value of OriginalClass.bar when called by OriginalClass.foo, the correct approach is to configure the mock’s return_value (if the specific value is predictable) or to verify that the mock returns the expected result type by checking the return value of the full test execution, as the mock itself handles the delegation.
Root Cause
The root cause of the confusion is a misunderstanding of how unittest.mock.patch and the wraps parameter function together. Specifically:
- Misuse of
wraps: Thewrapsparameter accepts a callable (likepass_wrap) that is executed when the mock is called. However, ifpass_wrapdoes not explicitly return a value (it only prints), it implicitly returnsNone. WhenOriginalClass.foocallsself.bar(a,b), it receivesNone, causing the subsequent logic (or logic in the test) to fail or behave unexpectedly. - Expecting side-effect capture via wrapper: The user is trying to use the wrapper function
pass_wrapto capture the return value, butpass_wrapis designed to intercept the arguments (side effects), not the return value. - Misunderstanding
mock.call_args:mock.call_argsrecords the arguments used to call the mock. It does not contain the return value. To access the return value, one must inspect the return value of the method that called the mocked method (e.g.,foo) or inspect the mock object’s configuration if the return value is static.
Why This Happens in Real Systems
This issue arises frequently in legacy codebases or complex architectures due to the following factors:
- Tight Coupling: The method under test (
foo) directly instantiates dependencies or calls sibling methods (bar) rather than accepting them as injection points. This makes it impossible to isolatebarwithout patchingOriginalClassitself. - Side Effects vs. Return Values: Developers often focus on logging or pre-processing side effects (using
wrapsorside_effect) and forget that the consuming method (foo) depends strictly on the return value to continue execution. - Ambiguous Documentation: The
wrapsdocumentation emphasizes “performs the original method’s behavior,” which implies the original return value is preserved. However, if the user replaceswrapswith a custom function that lacks a return statement, the original behavior is broken. - Implicit Dependencies: In the example,
foorelies onbarreturning a number. Ifbaris patched incorrectly,foomay fail silently or pass incorrect data tobaz, leading to false positives in tests.
Real-World Impact
Failing to correctly capture or handle return values in mocks leads to several production and testing risks:
- False Positive Tests: If a mock returns
None(the default) instead of the expected data, assertions on the return value offoomay pass incorrectly or fail, masking real bugs in the logic. - Runtime Errors: If
fooattempts to perform operations on aNonereturn value frombar(e.g., arithmetic or method calls), it raisesAttributeErrororTypeErrorduring test execution. - Brittle Test Suites: Over-reliance on
wrapswithout ensuring return values match the original signature leads to brittle tests that break when implementation details change. - Debugging Overhead: Engineers waste time debugging why
foobehaves unexpectedly, only to realize the patchedbarreturnedNoneinstead of the expected calculation result.
Example or Code
The following code demonstrates the correct way to capture the return value of bar when it is called internally by foo. We mock bar to return a specific value (or the original calculation) and verify that foo receives it correctly.
import unittest
from unittest.mock import patch
import sys
import io
# Module to test (assumed to be in module_1.py for context)
# OriginalClass
# def foo(self, a, b):
# return_val = self.bar(a, b) # We need to inspect this interaction
# is_validated = self.baz(return_val)
# return is_validated
#
# def bar(self, a, b):
# return a + b
#
# def baz(self, result):
# return result > 2
class TestOriginalClass(unittest.TestCase):
# Scenario 1: We want to verify 'bar' is called and capture its return value
# indirectly by checking the final result of 'foo'.
@patch("module_1.OriginalClass.bar")
def test_foo_logic_with_mocked_bar_return(self, mock_bar):
# Setup: Configure the mock to return a specific value when 'bar' is called.
# This allows us to control inputs to the subsequent method 'baz'.
mock_bar.return_value = 5
# Execution
# We instantiate the class. Note: In real unit tests, if 'bar' is a method
# of the same class, patching works as shown.
from module_1 import OriginalClass
instance = OriginalClass()
# When 'foo' calls self.bar(1, 2), it intercepts the call
result = instance.foo(1, 2)
# Assertion 1: Verify 'bar' was called with expected args
mock_bar.assert_called_with(1, 2)
# Assertion 2: Verify the logic used the return value of 'bar'.
# Since 'bar' returned 5, 'baz(5)' (5 > 2) should return True.
self.assertTrue(result)
# Scenario 2: Using 'wraps' to capture args AND return value
# If you must use 'wraps' (e.g., to print args), the wrapped function
# MUST return the value that the original method would return.
def mock_bar_logic(self, a, b):
# This simulates the original 'bar' logic
result = a + b
print(f"Captured args: {a}, {b} -> Calculated result: {result}")
return result
@patch("module_1.OriginalClass.bar", wraps=mock_bar_logic)
def test_foo_with_wraps_capturing_return(self, mock_bar):
from module_1 import OriginalClass
instance = OriginalClass()
# 'foo' calls 'bar'. The mock delegates to 'mock_bar_logic'.
# 'mock_bar_logic' prints args and returns the calculated value.
result = instance.foo(1, 2)
# We can inspect call_args, but the critical part is that
# 'foo' received the return value (3) from the wrapped function.
self.assertEqual(mock_bar.call_args.args, (1, 2))
# Verify the final outcome (3 > 2 is True)
self.assertTrue(result)
How Senior Engineers Fix It
Senior engineers approach this by decoupling the observation of the call from the execution of the logic.
-
Mock the Return Value Directly:
Instead of trying to “catch” the return value in transit, define what the return value should be usingmock.return_value. This ensures the code under test (foo) receives the data it expects, allowing you to verify the downstream logic (e.g.,baz). -
Use
wrapsOnly for Side Effects:
Ifwrapsis necessary (e.g., for logging arguments), ensure the wrapping function calculates and returns the original value. If the original method is complex to replicate, usewrapson the actual method (if accessible) or re-implement the minimal logic required to produce the return value in the wrapper. -
Assert the Final State:
Instead of asserting the internal return value ofbar(which is an implementation detail), assert the final return value offoo. IffooreturnsTruewhenbarreturnsX, and you mockbarto returnX, you have effectively captured and verified the return value flow. -
Refactor for Dependency Injection (Long-term Fix):
Ifbaris critical to test in isolation, refactorOriginalClassto acceptbaras an argument or use a strategy pattern. This removes the need for patching the class itself.
Why Juniors Miss It
Junior engineers often struggle with this concept due to the following gaps in understanding:
- Treating
wrapsas a “Listener”: They viewwrapsas a passive listener that simply records data. They forget thatwrapsbecomes the active implementation during the test, and if it doesn’t return a value, the production code breaks. - Confusing
call_argswith Return Values: They look atmock.call_argsexpecting to find the result of the function, not realizingcall_argsonly holds the input data (args/kwargs). - Over-complicating the Wrapper: Instead of using
mock.return_value(the standard way to control return values), they try to manipulate thewrapsparameter to simultaneously log arguments and propagate return values, which requires writing boilerplate code to pass the return value back. - Lack of Mental Model for the Call Stack: They struggle to visualize that
OriginalClass.foois the driver. Whenfoocallsself.bar, it expects a specific data type back. If the test doesn’t provide that data type (via the mock),foofails. The goal is to supplyfoowith valid data to testfoo‘s logic.