Testing with Mocking

I have been working with on some unit tests recently with mocking. There are some traps that I falled into and I want to document it here.
python
Author

noklam

Published

May 30, 2022

What is Mocking?

pytest-mock

One of the mainstream mocking library is the standard one from unittest, there are also pytest plugin pytest-mock which wraps on unittest.

%load_ext ipython_pytest
%%pytest

def test_sum():
    assert 1 == 1
============================= test session starts =============================
platform win32 -- Python 3.8.5, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: C:\Users\lrcno\AppData\Local\Temp\tmpiih077gv
plugins: anyio-3.5.0, cov-3.0.0, mock-1.13.0
collected 1 item

_ipytesttmp.py .                                                         [100%]

============================== 1 passed in 0.06s ==============================

Mocking is important for a few reasons. * You want to have fast unittest (within second) * You don’t want to put loading or have any side-effect to your actual servers/database (e.g. mock writing to a database)

Mock and MagicMock

There are two main mock object you can used with the standard unittest library from unittest.mock.

from unittest.mock import Mock, MagicMock, patch

Mock

mock = Mock()

With the Mock object, you can treat it like a magic object that have any attributes or methods.

mock.super_method(), mock.attribute_that_does_not_exist_at_all
(<Mock name='mock.super_method()' id='1587554283232'>,
 <Mock name='mock.attribute_that_does_not_exist_at_all' id='1587554282512'>)
str(mock)
"<Mock id='1587554282848'>"

MagicMock

The “magic” comes from the magic methods of python object, for example, when you add two object together, it is calling the __add__ magic method under the hook.

mock + mock
TypeError: unsupported operand type(s) for +: 'Mock' and 'Mock'
magic_mock = MagicMock()
magic_mock + magic_mock
<MagicMock name='mock.__add__()' id='1587563722784'>

With MagicMock, you get these magic methods for free, this is why adding two mock will not throw an error but adding two Mock will result in a TypeError

Let say we want to mock the pandas.read_csv function, because we don’t actually want it to read a data, but just return some mock data whenever it is called. It’s easier to explain with an example.

Mocking with real library

%%pytest
import pandas as pd

def test_read_csv(mocker):  # mocker is a special pytest fixture, so even though we haven't define it here but pytest understands it.
    mocker.patch("pandas.read_csv", return_value = "fake_data")
    assert pd.read_csv("some_data") == "fake_data"
============================= test session starts =============================
platform win32 -- Python 3.8.5, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: C:\Users\lrcno\AppData\Local\Temp\tmpka9zv6ev
plugins: anyio-3.5.0, cov-3.0.0, mock-1.13.0
collected 1 item

_ipytesttmp.py .                                                         [100%]

============================== 1 passed in 0.09s ==============================

In reality, you should get a Dataframe object, but here we mock the return value to return a str, and you can see the test actually pass.

mocker.patch with create=True

%%pytest
import pandas as pd

def test_read_csv(mocker):  # mocker is a special pytest fixture, so even though we haven't define it here but pytest understands it.
    mocker.patch("pandas.read_special_csv", return_value = "fake_data", create=False)
    assert pd.read_special_csv("some_data") == "fake_data"
============================= test session starts =============================
platform win32 -- Python 3.8.5, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: C:\Users\lrcno\AppData\Local\Temp\tmpzbddlxxg
plugins: anyio-3.5.0, cov-3.0.0, mock-1.13.0
collected 1 item

_ipytesttmp.py F                                                         [100%]

================================== FAILURES ===================================
________________________________ test_read_csv ________________________________

mocker = <pytest_mock.plugin.MockFixture object at 0x00000171B28B1820>

    def test_read_csv(mocker):  # mocker is a special pytest fixture, so even though we haven't define it here but pytest understands it.
>       mocker.patch("pandas.read_special_csv", return_value = "fake_data", create=False)

_ipytesttmp.py:4: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
..\..\..\..\miniconda3\lib\site-packages\pytest_mock\plugin.py:193: in __call__
    return self._start_patch(self.mock_module.patch, *args, **kwargs)
..\..\..\..\miniconda3\lib\site-packages\pytest_mock\plugin.py:157: in _start_patch
    mocked = p.start()
..\..\..\..\miniconda3\lib\unittest\mock.py:1529: in start
    result = self.__enter__()
..\..\..\..\miniconda3\lib\unittest\mock.py:1393: in __enter__
    original, local = self.get_original()
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <unittest.mock._patch object at 0x00000171B28B10D0>

    def get_original(self):
        target = self.getter()
        name = self.attribute
    
        original = DEFAULT
        local = False
    
        try:
            original = target.__dict__[name]
        except (AttributeError, KeyError):
            original = getattr(target, name, DEFAULT)
        else:
            local = True
    
        if name in _builtins and isinstance(target, ModuleType):
            self.create = True
    
        if not self.create and original is DEFAULT:
>           raise AttributeError(
                "%s does not have the attribute %r" % (target, name)
            )
E           AttributeError: <module 'pandas' from 'c:\\users\\lrcno\\miniconda3\\lib\\site-packages\\pandas\\__init__.py'> does not have the attribute 'read_special_csv'

..\..\..\..\miniconda3\lib\unittest\mock.py:1366: AttributeError
=========================== short test summary info ===========================
FAILED _ipytesttmp.py::test_read_csv - AttributeError: <module 'pandas' from ...
============================== 1 failed in 0.43s ==============================

Now we fail the test because pandas.read_special_csv does not exist. However, with create=True you can make the test pass again. Normally you won’t want to do this, but it is an option that available.

%%pytest
import pandas as pd

def test_read_csv(mocker):  # mocker is a special pytest fixture, so even though we haven't define it here but pytest understands it.
    mocker.patch("pandas.read_special_csv", return_value = "fake_data", create=True)
    assert pd.read_special_csv("some_data") == "fake_data"
============================= test session starts =============================
platform win32 -- Python 3.8.5, pytest-6.2.5, py-1.11.0, pluggy-1.0.0
rootdir: C:\Users\lrcno\AppData\Local\Temp\tmphqbckliw
plugins: anyio-3.5.0, cov-3.0.0, mock-1.13.0
collected 1 item

_ipytesttmp.py .                                                         [100%]

============================== 1 passed in 0.10s ==============================

More often, you would want your mock resemble your real object, which means it has the same attributes and method, but it should fails when the method being called isn’t valid. You may specify the return_value with the mock type

%%pytest -vvv
import pandas as pd
from unittest.mock import Mock
import pytest

def test_read_csv_valid_method(mocker):  # mocker is a special pytest fixture, so even though we haven't define it here but pytest understands it.
    mocker.patch("pandas.read_csv", return_value = Mock(pd.DataFrame))
    df =  pd.read_csv("some_data")
    df.mean()  # A DataFrame method

def test_read_csv_invalid_method(mocker):  # mocker is a special pytest fixture, so even though we haven't define it here but pytest understands it.
    mocker.patch("pandas.read_csv", return_value = Mock(pd.DataFrame))
    df =  pd.read_csv("some_data")
    with pytest.raises(Exception):
        df.not_a_dataframe_method()
============================= test session starts =============================
platform win32 -- Python 3.8.5, pytest-6.2.5, py-1.11.0, pluggy-1.0.0 -- c:\users\lrcno\miniconda3\python.exe
cachedir: .pytest_cache
rootdir: C:\Users\lrcno\AppData\Local\Temp\tmpyfiqtkoy
plugins: anyio-3.5.0, cov-3.0.0, mock-1.13.0
collecting ... collected 2 items

_ipytesttmp.py::test_read_csv_valid_method PASSED                        [ 50%]
_ipytesttmp.py::test_read_csv_invalid_method PASSED                      [100%]

============================== 2 passed in 0.16s ==============================