Python Unittest: Is there a way to capture the return value of a method that is called in the call stack?

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: The wraps parameter accepts a callable (like pass_wrap) that is executed when the mock is called. However, if pass_wrap does not explicitly return a value (it only prints), it implicitly returns None. When OriginalClass.foo calls self.bar(a,b), it receives None, 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_wrap to capture the return value, but pass_wrap is designed to intercept the arguments (side effects), not the return value.
  • Misunderstanding mock.call_args: mock.call_args records 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 isolate bar without patching OriginalClass itself.
  • Side Effects vs. Return Values: Developers often focus on logging or pre-processing side effects (using wraps or side_effect) and forget that the consuming method (foo) depends strictly on the return value to continue execution.
  • Ambiguous Documentation: The wraps documentation emphasizes “performs the original method’s behavior,” which implies the original return value is preserved. However, if the user replaces wraps with a custom function that lacks a return statement, the original behavior is broken.
  • Implicit Dependencies: In the example, foo relies on bar returning a number. If bar is patched incorrectly, foo may fail silently or pass incorrect data to baz, 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 of foo may pass incorrectly or fail, masking real bugs in the logic.
  • Runtime Errors: If foo attempts to perform operations on a None return value from bar (e.g., arithmetic or method calls), it raises AttributeError or TypeError during test execution.
  • Brittle Test Suites: Over-reliance on wraps without ensuring return values match the original signature leads to brittle tests that break when implementation details change.
  • Debugging Overhead: Engineers waste time debugging why foo behaves unexpectedly, only to realize the patched bar returned None instead 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.

  1. Mock the Return Value Directly:
    Instead of trying to “catch” the return value in transit, define what the return value should be using mock.return_value. This ensures the code under test (foo) receives the data it expects, allowing you to verify the downstream logic (e.g., baz).

  2. Use wraps Only for Side Effects:
    If wraps is necessary (e.g., for logging arguments), ensure the wrapping function calculates and returns the original value. If the original method is complex to replicate, use wraps on the actual method (if accessible) or re-implement the minimal logic required to produce the return value in the wrapper.

  3. Assert the Final State:
    Instead of asserting the internal return value of bar (which is an implementation detail), assert the final return value of foo. If foo returns True when bar returns X, and you mock bar to return X, you have effectively captured and verified the return value flow.

  4. Refactor for Dependency Injection (Long-term Fix):
    If bar is critical to test in isolation, refactor OriginalClass to accept bar as 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 wraps as a “Listener”: They view wraps as a passive listener that simply records data. They forget that wraps becomes the active implementation during the test, and if it doesn’t return a value, the production code breaks.
  • Confusing call_args with Return Values: They look at mock.call_args expecting to find the result of the function, not realizing call_args only 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 the wraps parameter 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.foo is the driver. When foo calls self.bar, it expects a specific data type back. If the test doesn’t provide that data type (via the mock), foo fails. The goal is to supply foo with valid data to test foo‘s logic.