Search code examples
unit-testingdependency-injectionmockingpytestpytest-mock

Python Mocking - How to obtain call arguments from a mock that is passed to another mock as a function argument?


I am not sure about the title of this question, as it is not easy to describe the issue with a single sentence. If anyone can suggest a better title, I'll edit it.

Consider this code that uses smbus2 to communicate with an I2C device:

# device.py
import smbus2

def set_config(bus):
    write = smbus2.i2c_msg.write(0x76, [0x00, 0x01])
    read = smbus2.i2c_msg.read(0x76, 3)
    bus.i2c_rdwr(write, read)

I wish to unit-test this without accessing I2C hardware, by mocking the smbus2 module as best I can (I've tried mocking out the entire smbus2 module, so that it doesn't even need to be installed, but had no success, so I'm resigned to importing smbus2 in the test environment even if it's not actually used - no big deal so far, I'll deal with that later):

# test_device.py
# Depends on pytest-mock
import device

def test_set_config(mocker):
    mocker.patch('device.smbus2')
    smbus = mocker.MagicMock()

    device.set_config(smbus)

    # assert things here...
    breakpoint()

At the breakpoint, I'm inspecting the bus mock in pdb:

(Pdb) p smbus
<MagicMock id='140160756798784'>

(Pdb) p smbus.method_calls
[call.i2c_rdwr(<MagicMock name='smbus2.i2c_msg.write()' id='140160757018400'>, <MagicMock name='smbus2.i2c_msg.read()' id='140160757050688'>)]

(Pdb) p smbus.method_calls[0].args
(<MagicMock name='smbus2.i2c_msg.write()' id='140160757018400'>, <MagicMock name='smbus2.i2c_msg.read()' id='140160757050688'>)

(Pdb) p smbus.method_calls[0].args[0]
<MagicMock name='smbus2.i2c_msg.write()' id='140160757018400'>

Unfortunately, at this point, the arguments that were passed to write() and read() have been lost. They do not seem to have been recorded in the smbus mock and I've been unable to locate them in the data structure.

Interestingly, if I break in the set_config() function, just after write and read assignment, and inspect the mocked module, I can see:

(Pdb) p smbus2.method_calls
[call.i2c_msg.write(118, [160, 0]), call.i2c_msg.read(118, 3)]

(Pdb) p smbus2.method_calls[0].args
(118, [160, 0])

So the arguments have been stored as a method_call in the smbus2 mock, but not copied over to the smbus mock that is passed into the function.

Why is this information not retained? Is there a better way to test this function?


I think this can be summarised as this:

In [1]: from unittest.mock import MagicMock

In [2]: foo = MagicMock()

In [3]: bar = MagicMock()

In [4]: w = foo.write(1, 2)

In [5]: r = foo.read(1, 2)

In [6]: bar.func(w, r)
Out[6]: <MagicMock name='mock.func()' id='140383162348976'>

In [7]: bar.method_calls
Out[7]: [call.func(<MagicMock name='mock.write()' id='140383164249232'>, <MagicMock name='mock.read()' id='140383164248848'>)]

Note that the bar.method_calls list contains calls to the functions .write and .read (good), but the parameters that were passed to those functions are missing (bad). This seems to undermine the usefulness of such mocks, since they don't interact as I would expect. Is there a better way to handle this?


Solution

  • For anyone coming across this, I posed a variation of this problem in another question, and the result was quite satisfactory:

    https://stackoverflow.com/a/73739343/

    In a nutshell, create a TraceableMock class, derived from MagicMock, that returns a new mock that keeps track of its parent, as well as the parameters of the function call that led to this mock being created. Together, there is enough information to verify that the correct function was called, and the correct parameters were supplied.