3

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?

davidA
  • 12,528
  • 9
  • 64
  • 96
  • The code for `set_config` takes a `bus` parameter, but doesn't seem to use it -- rather, it uses the `smbus` module directly. Could that be an issue? – Samuel Dion-Girardeau Sep 16 '22 at 05:19
  • Did you try simply`mocker.patch('smbus2')`? – Adrian Sep 16 '22 at 20:41
  • @SamuelDion-Girardeau oops - somehow a line is missing from the code I pasted in - sorry! I'll fix that now. The missing line does use `bus`. – davidA Sep 18 '22 at 22:00
  • @Adrian yes, I have tried this, and this results in Mocks for the `read` and `write` variables, which is OK, but they do not contain the parameters used to create them, because these are stored in the `smbus2.i2c_msg` mock instead. Therefore they are not associated with the `bus` mock, and the test isn't able to make the connection in a reliable way (the `smbus2` mock can be inspected by the test, and the parameters are present in there, but when more `i2c_msg.read/write` calls are made, there's no link to this particular call, so it's not useful). – davidA Sep 18 '22 at 22:04
  • @davidA Thanks for updating, this makes more sense now! Just dropped an answer, hope it helps. – Samuel Dion-Girardeau Sep 19 '22 at 19:54

3 Answers3

2

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)
Samuel Dion-Girardeau
  • 2,790
  • 1
  • 29
  • 37
  • Thank you for your answer. I think the more standard approach is probably the best way to do it. The complication is that there are other calls to `.read` and `.write` which get in the way of the ones I am specifically looking at, but perhaps with judicious use of `reset_mock` I can isolate them sufficiently. I will persevere... – davidA Sep 19 '22 at 23:41
1

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
Adrian
  • 591
  • 4
  • 12
  • Thank you for answering. In general I like this approach, and I agree that Mocks don't solve everything, however there's a nuance that isn't quite covered by your answer - the code under test makes calls to `smbus2.i2c_msg.write` and `smbus2.i2c_msg.read` to create the parameters, and ideally these would be mocked too as they are non-trivial data structures. In my current code I'm letting `set_config` call the `smbus2`-supplied functions, then comparing with a custom assert, but it's messy and I'd prefer to find a way to verify that `.read` and `.write` were called with the correct parameters. – davidA Sep 19 '22 at 23:38
0

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.

davidA
  • 12,528
  • 9
  • 64
  • 96