Skip to content

Instantly share code, notes, and snippets.

@weshouman
Last active September 1, 2024 22:57
Show Gist options
  • Save weshouman/439345e1bb90b53121fcadd6c6be2b83 to your computer and use it in GitHub Desktop.
Save weshouman/439345e1bb90b53121fcadd6c6be2b83 to your computer and use it in GitHub Desktop.
Advanced mocking tips for builtins.open in python

Basic Mocking with mock_open

The simplest approach is to use mock.mock_open to simulate reading from a file. This method works well when one only needs a single return value.

import unittest
from unittest import mock

def core(path):
    with open(path, "r") as f:
        return f.read()

class TestCore(unittest.TestCase):
    @mock.patch("builtins.open", new_callable=mock.mock_open, read_data="foo content")
    def test_core_single_file(self, mock_file):
        result = core("foo")
        self.assertEqual(result, "foo content")
        self.assertEqual(mock_file.call_count, 1)

if __name__ == "__main__":
    unittest.main()

Limitation: The mock_open provided by unittest.mock only supports a single read_data value, which means it's not suitable for cases where one needs different return values for different files, for example when the accessing 2 files one for reading and another for writing within the same UUT.


Customizing mock_open for Multiple Files

Return different content for different files by conditionally returning different mock objects.

import unittest
from unittest import mock

mock_files = {}

def mock_open(path, *args, **kwargs):
    if path == "foo.txt":
        mock_files[path] = mock.mock_open(read_data="foo content")
    elif path == "bar.txt":
        mock_files[path] = mock.mock_open(read_data="bar content")
    else:
        return open(path, *args, **kwargs)  # fallback to the real open
    
    return mock_files[path](*args, **kwargs)

def core(path):
    with open(path, "r") as f:
        return f.read()

class TestCore(unittest.TestCase):
    @mock.patch("builtins.open", mock_open)
    def test_core_multiple_files(self):
        # Test foo.txt
        result_foo = core("foo.txt")
        self.assertEqual(result_foo, "foo content")

        # Test bar.txt
        result_bar = core("bar.txt")
        self.assertEqual(result_bar, "bar content")

        # Check mock_calls
        print(mock_files["foo.txt"].mock_calls)
        print(mock_files["bar.txt"].mock_calls)

if __name__ == "__main__":
    unittest.main()

Pros: Allows different content for different files
Cons: Manual tracking of each mock file object


Managing Multiple Mocks from a Class

Encapsulating the management of multiple mocks into a dedicated class allowing different return values for different files.

import unittest
from unittest import mock

class MockOpenManager:
    mocks = {}

    @staticmethod
    def mock_open(path, *args, **kwargs):
        if path not in MockOpenManager.mocks:
            if path == "foo.txt":
                MockOpenManager.mocks[path] = mock.mock_open(read_data="foo content")
            elif path == "bar.txt":
                MockOpenManager.mocks[path] = mock.mock_open(read_data="bar content")
            else:
                return open(path, *args, **kwargs)  # fallback to the real open

        return MockOpenManager.mocks[path](*args, **kwargs)

def core(path):
    with open(path, "r") as f:
        return f.read()

class TestCore(unittest.TestCase):
    @mock.patch("builtins.open", new=MockOpenManager.mock_open)
    def test_core_mock_manager(self):
        # Test foo.txt
        result_foo = core("foo.txt")
        self.assertEqual(result_foo, "foo content")

        # Test bar.txt
        result_bar = core("bar.txt")
        self.assertEqual(result_bar, "bar content")

        # Validate the mock calls
        print("Mock calls for foo.txt:", MockOpenManager.mocks["foo.txt"].mock_calls)
        print("Mock calls for bar.txt:", MockOpenManager.mocks["bar.txt"].mock_calls)

if __name__ == "__main__":
    unittest.main()

Pros: Mock management is encapsulated in a class. Further extension and modification could be achieved
Cons: Class is required to be modified for extension


Plugin Management of Multiple Mocks

Use mock patterns to manage mocks.

import unittest
from unittest import mock

class DynamicMockOpen:
    mocks = []

    @staticmethod
    def add_mock(filename_check, read_data=None, write_data_func=None):
        if read_data is not None:
            mock_file = mock.mock_open(read_data=read_data)
        else:
            mock_file = mock.mock_open()
            if write_data_func:
                mock_file().write.side_effect = write_data_func
        
        DynamicMockOpen.mocks.append((filename_check, mock_file))

    @staticmethod
    def mock_open(*args, **kwargs):
        filepath = args[0]
        for check, mock_file in DynamicMockOpen.mocks:
            if check(filepath):
                return mock_file(*args, **kwargs)
        return open(filepath, *args, **kwargs)  # Fallback to the real open

    @staticmethod
    def get_mock_calls(filepath):
        for check, mock_file in DynamicMockOpen.mocks:
            if check(filepath):
                return mock_file.mock_calls
        return []

def core(path, mode='r', data=None):
    with open(path, mode) as f:
        if mode == 'r':
            return f.read()
        elif mode == 'w' and data:
            f.write(data)
            return True
    return False

class TestCore(unittest.TestCase):
    def setUp(self):
        self.dynamic_mock = DynamicMockOpen()
        self.dynamic_mock.add_mock(
            filename_check=lambda filename: filename == "foo.txt",
            read_data="foo content"
        )
        self.dynamic_mock.add_mock(
            filename_check=lambda filename: filename == "bar.txt",
            write_data_func=lambda data: len(data)
        )
    
    dynamic_mock_mgr = DynamicMockOpen

    @mock.patch("builtins.open", new=dynamic_mock_mgr.mock_open)
    def test_core_dynamic_mock(self):
        # Test reading from "foo.txt"
        result_foo = core("foo.txt")
        self.assertEqual(result_foo, "foo content")
        
        # Test writing to "bar.txt"
        result_bar_write = core("bar.txt", mode='w', data="bar content")
        self.assertTrue(result_bar_write)
        
        # Validate the mock calls
        print("Mock calls for foo.txt:", self.dynamic_mock.get_mock_calls("foo.txt"))
        print("Mock calls for bar.txt:", self.dynamic_mock.get_mock_calls("bar.txt"))

if __name__ == "__main__":
    unittest.main()

Pros: Extension does not require modifying the class
Cons: Code structure is not OO, only one instance of the class


Object Oriented Mock Management

import unittest
from unittest import mock

class DynamicMockOpen:
    def __init__(self):
        self.mock_patterns = []
        self.mocks = []
    
    def add_mock_pattern(self, filename_check, read_data=None):
        if read_data is not None:
            mock_file = mock.mock_open(read_data=read_data)
        else:
            mock_file = mock.mock_open()
        
        self.mock_patterns.append((filename_check, mock_file))

    def mock_open(self, *args, **kwargs):
        filepath = args[0]
        for check, mock_file in self.mock_patterns:
            if check(filepath):
                out = mock_file(*args, **kwargs)
                self.mocks.append(out)
                return out

        out = open(filepath, *args, **kwargs)
        self.mocks.append(out)
        return out # Fallback to the real open
    
    def get_mock_calls(self, filepath):
        for check, mock_file in self.mock_patterns:
            if check(filepath):
                return mock_file.mock_calls
        return []

    @staticmethod
    def get_written_data(mock):
        CALL_NUM = CALL_DEREF = ARG_NUM = 0
        return mock.write.call_args_list[CALL_NUM][CALL_DEREF][ARG_NUM]

def core(path, mode='r', data=None):
    with open(path, mode) as f:
        if mode == 'r':
            return f.read()
        elif mode == 'w' and data:
            f.write(data)
            return True
    return False

class TestCore(unittest.TestCase):
    dynamic_mock = DynamicMockOpen()

    def setUp(self):
        TestCore.dynamic_mock.add_mock_pattern(
            filename_check=lambda filename: filename == "foo.txt",
            read_data="foo content"
        )
        TestCore.dynamic_mock.add_mock_pattern(
            filename_check=lambda filename: filename == "bar.txt"
        )

        self.assertTrue(TestCore.dynamic_mock.mock_patterns[0][0]("foo.txt"))
        self.assertTrue(TestCore.dynamic_mock.mock_patterns[0][1]().read() == "foo content")
        self.assertTrue(TestCore.dynamic_mock.mock_patterns[1][0]("bar.txt"))

    # NOTE: we are using the new_callable again as the new method won't pass the self,
    #       but rather the args and kwargs
    @mock.patch("builtins.open", new_callable=lambda: TestCore.dynamic_mock.mock_open)
    def test_core_dynamic_mock(self, _):
        # Test reading from "foo.txt"
        result_foo = core("foo.txt")
        self.assertEqual(result_foo, "foo content")
        
        # Test writing to "bar.txt"
        result_bar_write = core("bar.txt", mode='w', data="bar content")

        self.assertTrue(DynamicMockOpen.get_written_data(self.dynamic_mock.mocks[1]) == 'bar content')
        self.assertTrue(result_bar_write)

        # Validate the mock calls
        print("Mock calls for foo.txt:", self.dynamic_mock.get_mock_calls("foo.txt"))
        print("Mock calls for bar.txt:", self.dynamic_mock.get_mock_calls("bar.txt"))

if __name__ == "__main__":
    unittest.main()

Pros: Highly flexible and OO
Cons: High complexity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment