Home > front end >  Python Mocking - How to obtain call arguments from a mock that is passed to another mock as a functi
Python Mocking - How to obtain call arguments from a mock that is passed to another mock as a functi

Time:09-20

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?

CodePudding user response:

I just realized that you use dependency injection and that you should take advantage of this.

  1. This would be the clean approach.
  2. Mocks can behave unexpected/nasty (which does not mean that they are evil - only sometime.... counterintuitive)

I would recommend following test structure:

# test_device.py
import device

def test_set_config():
    dummy_bus = DummyBus()

    device.set_config(dummy_bus)

    # assert things here...
    assert dummy_bus.read_data == 'foo'
    assert dummy_bus.write_data == 'bar'

    breakpoint()

class DummyBus:
    def __init__(self):
        self.read_data = None
        self.write_data = None

    def i2c_rdwr(write_input, read_input):
        self.read_data = read_input
        self.write_data = write_input

CodePudding user response:

The reason you can't access the calls to write and read is that they themselves are the return_value of another mock. What you are trying to do is access the mock "parent" (Using the terminology here: https://docs.python.org/3/library/unittest.mock.html).

It actually is possible to access the parent, but I'm not sure it's a good idea, since it used an undocumented and private attribute of the MagicMock object, _mock_new_parent.

def test_set_config(mocker):
    """Using the undocumented _mock_new_parent attribute"""

    mocker.patch('device.smbus2')
    smbus = mocker.MagicMock()

    device.set_config(smbus)

    # Retrieving the `write` and `read` values passed to `i2c_rdwr`.
    mocked_write, mocked_read = smbus.i2c_rdwr.call_args[0]

    # Making some assertions about how the corresponding functions were called.
    mocked_write._mock_new_parent.assert_called_once_with(0x76, [0x00, 0x01])
    mocked_read._mock_new_parent.assert_called_once_with(0x76, 3)

You can check that the assertions work by using some bogus values instead, and you'll see the pytest assertion errors.

A simpler, and more standard approach IMO is to look at the calls from the module mock directly:

def test_set_config_2(mocker):
    """ Using the module mock directly"""

    mocked_module = mocker.patch('device.smbus2')
    smbus = mocker.MagicMock()

    device.set_config(smbus)

    mocked_write = mocked_module.i2c_msg.write
    mocked_read = mocked_module.i2c_msg.read

    mocked_write.assert_called_once_with(0x76, [0x00, 0x01])
    mocked_read.assert_called_once_with(0x76, 3)
  • Related