Wednesday, September 14, 2022

Mock Everything

 A mock object is meant to simulate any API for the purposes of testing.

The python standard library includes MagicMock.

>>> from unittest.mock import MagicMock
>>> mock = MagicMock()
>>> mock.a
<MagicMock name='mock.a' id='281473174436496'>
>>> mock[0]
<MagicMock name='mock.__getitem__()' id='281473165975360'>
>>> mock + 1
<MagicMock name='mock.__add__()' id='281473165479264'>

However, there is one place where MagicMock fails.

>>> a, b = mock
Traceback (most recent call last):
  File "", line 1, in 
ValueError: not enough values to unpack (expected 2, got 0)

The syntax which allows a comma separated series of names on the left to unpack the value on the right is known as sequence unpacking in python.

The reason MockObject is incompatible with sequence unpacking is due to a limitation of operator overloading in python when it comes to this piece of syntax.  Let's take a look at how the failing line compiles:

>>> import dis
>>> dis.dis(compile("a, b = fake", "string", "exec"))
  1           0 LOAD_NAME                0 (fake)
              2 UNPACK_SEQUENCE          2
              4 STORE_NAME               1 (a)
              6 STORE_NAME               2 (b)
              8 LOAD_CONST        

We pull fake onto the stack, run the opcode UNPACK_SEQUENCE with a parameter of 2, then store the results into a and b.  The issue is that MockObject.__iter__() has no way of knowing that UNPACK_SEQUENCE is expecting two values.

So, let's cheat and figure out how to do it anyway.

>>> import sys
>>> class MagicSequence:
...    def __iter__(self):
...       # get the python stack frame which is calling this one
...       frame = sys._getframe(1)
...       # which instruction index is that frame on
...       opcode_idx = frame.f_lasti
...       # what instruction does that index translate to
...       opcode = frame.f_code.co_code[opcode_idx]
...       # is it a sequence unpack instruction?
...       if opcode == 92:  # opcode.opmap['UNPACK_SEQUENCE']
...          # the next byte after the opcode is its parameter,
...          # which is the length that the sequence unpack expects
...          opcode_param = frame.f_code.co_code[opcode_idx + 1]
...          # return an iterator of the expected length
...          return iter(range(opcode_param))
...       return iter([])  # otherwise, return an empty iterator
...
>>> a, b = MagicSequence()
>>> a, b
(0, 1)
>>> a, b, c = MagicSequence()
>>> a, b, c
(0, 1, 2)