{ "cells": [ { "cell_type": "markdown", "id": "95d327f6463954ae", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ "# unittest.mock.Mock\n", "\n", "## Create a Mock object" ] }, { "cell_type": "code", "execution_count": 1, "id": "14814ab188445f3", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:05.901466Z", "start_time": "2024-08-21T14:51:05.890366Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [], "source": [ "from unittest import mock\n", "m = mock.Mock()" ] }, { "cell_type": "markdown", "id": "f9524ff595ff2e0f", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ " ## Define Mock object behavior\n", "### Set a default return value" ] }, { "cell_type": "code", "execution_count": 2, "id": "f7552c846014a6a8", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:05.918196Z", "start_time": "2024-08-21T14:51:05.914137Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [ { "data": { "text/plain": [ "42" ] }, "execution_count": 2, "metadata": {}, "output_type": "execute_result" } ], "source": [ "m.return_value = 42\n", "m()" ] }, { "cell_type": "markdown", "id": "a2a67f3504a7e7cf", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ "### Assign different return values for successive calls\n", "\n", "``side_effect`` must be of type list. Each time the mocked method is then called,\n", "the next value in the list is returned. If the ``side_effect`` list has no more\n", "values to return, the next call will fail with a ``StopIteration`` error.\n", "\n", "The ``return_value`` is like a constant side_effect, which always returns the same value.\n", "\n", "#### Have a Mock return string values when called" ] }, { "cell_type": "code", "execution_count": 3, "id": "3b52fbead5a78a8f", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:06.006125Z", "start_time": "2024-08-21T14:51:06.004045Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "foo\n", "bar\n", "baz\n" ] } ], "source": [ "m.side_effect = ['foo', 'bar', 'baz']\n", "print(m())\n", "print(m())\n", "print(m())" ] }, { "cell_type": "markdown", "id": "d62702cc124e6539", "metadata": {}, "source": [ "#### Have a Mock returns a new Mock for each function call" ] }, { "cell_type": "code", "execution_count": 4, "id": "af2721390e86d96c", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:06.101271Z", "start_time": "2024-08-21T14:51:06.098026Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "\n" ] } ], "source": [ "number = 10\n", "m = mock.Mock()\n", "m.get_mock.side_effect = [mock.Mock() for _ in range(number)]\n", "\n", "print(m.get_mock())\n", "print(m.get_mock())\n", "# may continue up to 'number'-times" ] }, { "cell_type": "markdown", "id": "52496c2dbd7d6040", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ "### Check number of calls on Mock object\n", "#### Check whether mock object has been called at least once" ] }, { "cell_type": "code", "execution_count": 5, "id": "3940370ed5ab33c1", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:06.163928Z", "start_time": "2024-08-21T14:51:06.161999Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [], "source": [ "m = mock.Mock()\n", "m()\n", "m.assert_called()" ] }, { "cell_type": "markdown", "id": "3c9e3f6277db5b71", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ "Returns *None*, if called, otherwise raises *AssertionError*\n", "\n", "#### Check whether mock object has been called exactly once" ] }, { "cell_type": "code", "execution_count": 6, "id": "40b4ddc36efb036c", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:06.181445Z", "start_time": "2024-08-21T14:51:06.179147Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "No, I wasn't called (yet)\n", "Yeah, now I was called\n" ] } ], "source": [ "m = mock.Mock()\n", "try:\n", " m.assert_called_once()\n", "except AssertionError:\n", " print(\"No, I wasn't called (yet)\")\n", "m()\n", "if not m.assert_called_once():\n", " print(\"Yeah, now I was called\")" ] }, { "cell_type": "markdown", "id": "6b2ec508d9046b42", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ "#### Get number of calls to mock object" ] }, { "cell_type": "code", "execution_count": 7, "id": "a82ddaa3883f2e2f", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:06.203388Z", "start_time": "2024-08-21T14:51:06.200684Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [ { "data": { "text/plain": [ "2" ] }, "execution_count": 7, "metadata": {}, "output_type": "execute_result" } ], "source": [ "m = mock.Mock()\n", "m()\n", "m()\n", "m.call_count" ] }, { "cell_type": "markdown", "id": "75cb3d8793e0def1", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ "### Get Mock call arguments\n", "#### Get call arguments from most recent Mock call" ] }, { "cell_type": "code", "execution_count": 8, "id": "c83c4b94f4e3c6af", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:06.217485Z", "start_time": "2024-08-21T14:51:06.214974Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [ { "data": { "text/plain": [ "call(1, foo='bar')" ] }, "execution_count": 8, "metadata": {}, "output_type": "execute_result" } ], "source": [ "m = mock.Mock()\n", "m(1, foo='bar')\n", "m.call_args" ] }, { "cell_type": "markdown", "id": "5f7e1f02c7b6e400", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ "If mock object hasn't been called yet, *None* is returned.\n", "\n", "#### Get call arguments of all previous Mock calls" ] }, { "cell_type": "code", "execution_count": 9, "id": "edf79f0b9313803b", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:06.241598Z", "start_time": "2024-08-21T14:51:06.239160Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [ { "data": { "text/plain": [ "[call(), call(1, foo='bar'), call(4, baz='bar2')]" ] }, "execution_count": 9, "metadata": {}, "output_type": "execute_result" } ], "source": [ "m = mock.Mock()\n", "m()\n", "m(1, foo='bar')\n", "m(4, baz='bar2')\n", "m.call_args_list" ] }, { "cell_type": "markdown", "id": "bb569654bcce9cdf", "metadata": { "collapsed": false, "jupyter": { "outputs_hidden": false } }, "source": [ "### Reset previous Mock calls (won't change mock configuration)" ] }, { "cell_type": "code", "execution_count": 10, "id": "8240bb6e6e870b95", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T14:51:06.263065Z", "start_time": "2024-08-21T14:51:06.260452Z" }, "collapsed": false, "jupyter": { "outputs_hidden": false } }, "outputs": [ { "data": { "text/plain": [ "[]" ] }, "execution_count": 10, "metadata": {}, "output_type": "execute_result" } ], "source": [ "m = mock.Mock()\n", "m()\n", "m(1, foo='bar')\n", "m(4, baz='bar2')\n", "m.reset_mock()\n", "m.call_args_list" ] }, { "cell_type": "markdown", "id": "36a6e16cb125cdc5", "metadata": {}, "source": [ "# unittest.mock.patch\n", "## Patch an import module\n", "``unittest.mock`` is able to patch imports in the module under test using the\n", "[patch](https://docs.python.org/3/library/unittest.mock.html#patch) function.\n", "\n", "``patch`` will intercept import statements identified by a string and return\n", "a Mock instance you can preconfigure using the techniques from ``unittest.Mock`` (see above).\n", "\n", "Module under test (``work.py``):" ] }, { "cell_type": "code", "execution_count": 11, "id": "39f3521f5cc6bdfb", "metadata": {}, "outputs": [], "source": [ "\n", "\n", "def work_on():\n", " path = os.getcwd()\n", " print(f'Working on {path}')\n", " return path" ] }, { "cell_type": "markdown", "id": "28affd4876b923ff", "metadata": {}, "source": [ "The project module imports ``os`` and uses its ``getcwd()`` method , which\n", "we want to mock in our test.\n", "\n", "### Patch using a context manager (``with`` statement)\n", "\n", "When using the context manager ``patch``, the patch ends when the ``with`` statement ends." ] }, { "cell_type": "code", "execution_count": 12, "id": "bd477c3de8d2f9c7", "metadata": { "ExecuteTime": { "end_time": "2024-08-21T18:34:32.300232Z", "start_time": "2024-08-21T18:34:32.287729Z" } }, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "test_using_context_manager (__main__.TestWorkMockingModule.test_using_context_manager) ... ok\n", "\n", "----------------------------------------------------------------------\n", "Ran 1 test in 0.002s\n", "\n", "OK\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Working on \n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "import unittest\n", "from unittest import TestCase\n", "\n", "\n", "class TestWorkMockingModule(TestCase):\n", "\n", " def test_using_context_manager(self):\n", " with mock.patch('work.os') as mocked_os:\n", " work_on()\n", " mocked_os.getcwd.assert_called_once()\n", "\n", "unittest.main(argv=[''], verbosity=2, exit=False)\n" ] }, { "cell_type": "markdown", "id": "d8419e2c-e797-41ab-a552-0f9d537e27d3", "metadata": {}, "source": [ "We specifically patch the ``work.os`` module, not ``os``, as this\n", "would patch ``os`` for all modules, not just for ``work.py``.\n", "\n", "* The test module imports the project module's method ``work_on()``\n", "* At the beginning of the test, the ``work.os`` module (which is the ``os``\n", " module in our ``work.py`` module) is patched with a [MagickMock][1] object\n", " (here called ``mocked_os``)\n", "* When the ``work_on()`` method is called afterward, that ``MagicMock`` object is\n", " called instead of the original module (``os``) - as no return value is defined\n", " ``work_on()`` returns another MagicMock object - as we are not testing the\n", " correctness of the return value here, only checking if the ``mocked_os`` executed\n", " the ``getcwd()``method, we do not care about the return value\n", "\n", "The ``as`` statement in the decorator is optional. You may as well not mock the\n", "entire ``work.os`` module, but rather define a return value for the single\n", "function, that is called (``getcwd()``):\n", "\n", "[1]: https://docs.python.org/3/library/unittest.mock.html?highlight=magicmock#unittest.mock.MagicMock" ] }, { "cell_type": "code", "execution_count": 13, "id": "4335adf6-81cd-4ea5-b4e7-9a0204612262", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "test_using_return_value (__main__.TestWorkMockingModule.test_using_return_value) ... ok\n", "\n", "----------------------------------------------------------------------\n", "Ran 1 test in 0.002s\n", "\n", "OK\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Working on testing\n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from unittest import TestCase, main\n", "\n", "\n", "class TestWorkMockingModule(TestCase):\n", " def test_using_return_value(self):\n", " with mock.patch('work.os.getcwd', return_value='testing'):\n", " assert work_on() == 'testing'\n", "\n", "main(argv=[''], verbosity=2, exit=False)" ] }, { "cell_type": "markdown", "id": "3f284eb6-78c9-4de1-899f-e54b0cd04a8e", "metadata": {}, "source": [ "### Patch using the decorator (@patch)\n", "\n", "To test project classes that interact with other project classes in isolation,\n", "the other project classes must be mocked (in order to determine test failure\n", "to a specific class and method, not by any of its dependency classes).\n", "\n", "Example:" ] }, { "cell_type": "code", "execution_count": 14, "id": "3bf74bdf-b4dc-4ee7-949a-eda82ee74574", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "test_using_decorator (__main__.TestWorkMockingModule.test_using_decorator) ... ok\n", "\n", "----------------------------------------------------------------------\n", "Ran 1 test in 0.002s\n", "\n", "OK\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Working on \n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 14, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from unittest import TestCase, mock, main\n", "\n", "from source.reference.python.cheat_sheets._file.work import work_on\n", "\n", "class TestWorkMockingModule(TestCase):\n", "\n", " @mock.patch('work.os')\n", " def test_using_decorator(self, mocked_os):\n", " work_on()\n", " mocked_os.getcwd.assert_called_once()\n", "\n", "main(argv=[''], verbosity=2, exit=False)" ] }, { "cell_type": "markdown", "id": "9d6d1d85-1a0d-49ed-83c2-66de41362c0e", "metadata": {}, "source": [ "Here the ``work.os`` module is replaced with a MagicMock object.\n", "\n", "[1]: https://docs.python.org/3/library/unittest.mock.html?highlight=magicmock#unittest.mock.MagicMock\n", "\n", "## Patching classes\n", "To test project classes that interact with other project classes in isolation,\n", "the other project classes must be mocked (in order to determine test failure\n", "to a specific class and method, not by any of its dependency classes).\n", "\n", "Example:\n", "\n", "### Project module (``worker.py``)\n", "\n", "In this module, the `Worker` class needs to be tested for these two things:\n", "\n", "- the `Worker` calls `Helper` with `db`\n", "- the `Worker` returns the expected path supplied by `Helper `" ] }, { "cell_type": "code", "execution_count": 15, "id": "df8b3b59-b07e-43fe-9742-84d95b84a27a", "metadata": {}, "outputs": [], "source": [ "\n", "\n", "\n", "class Helper:\n", "\n", " def __init__(self, path):\n", " self.path = path\n", "\n", " def get_path(self):\n", " base_path = os.getcwd()\n", " return os.path.join(base_path, self.path)\n", "\n", "\n", "class Worker:\n", "\n", " def __init__(self):\n", " self.helper = Helper('db')\n", "\n", " def work(self):\n", " path = self.helper.get_path()\n", " print(f'Working on {path}')\n", " return path" ] }, { "cell_type": "markdown", "id": "82feb87f-cf74-4369-8fdd-f119fa4d5a24", "metadata": {}, "source": [ "### Test module (test_worker.py)\n", "\n", "#### Patch entire class\n", "\n", "To test this, the `Worker` needs to be isolated from `Helper`,\n", "so that failures can be associated with the `Worker` class only.\n", "\n", "Consequently, the entire `Helper` class must be patched:\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "from worker import Worker" ] }, { "cell_type": "code", "execution_count": 16, "id": "fa8a464e-106d-49c0-a064-162b1eec81e2", "metadata": { "scrolled": true }, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "test_using_decorator (__main__.TestWorkMockingModule.test_using_decorator) ... ok\n", "test_patching_class (__main__.TestWorkerModule.test_patching_class) ... ok\n", "\n", "----------------------------------------------------------------------\n", "Ran 2 tests in 0.003s\n", "\n", "OK\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Working on \n", "Working on testing\n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from unittest import TestCase, main\n", "\n", "\n", "class TestWorkerModule(TestCase):\n", "\n", " def test_patching_class(self):\n", " with mock.patch('worker.Helper') as MockHelper:\n", " MockHelper.return_value.get_path.return_value = 'testing'\n", " worker = Worker()\n", " MockHelper.assert_called_once_with('db')\n", " self.assertEqual(worker.work(), 'testing')\n", "\n", "main(argv=[''], verbosity=2, exit=False)" ] }, { "cell_type": "markdown", "id": "c1ee9cc8-8b2c-4500-8e8e-be5bf847ba0b", "metadata": {}, "source": [ "Note the double ``return_value`` in the example, simply using \n", "``MockHelper.get_path.return_value`` would not work since in the code\n", "we call get_path on an instance, not the class itself.\n", "\n", "Alternatively, we can preconfigure the ``MockHelper`` instance and\n", "assign it as the return value of the ``worker.Helper`` call. Below,\n", "shown for both the context manager and the decorator way:" ] }, { "cell_type": "code", "execution_count": 18, "id": "dc62e085-dd44-4c3c-9fcd-d0865f1cda27", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "test_using_decorator (__main__.TestWorkMockingModule.test_using_decorator) ... ok\n", "test_patch_class_context_manager (__main__.TestWorkerModule.test_patch_class_context_manager) ... ok\n", "test_patch_class_decorator (__main__.TestWorkerModule.test_patch_class_decorator) ... ok\n", "\n", "----------------------------------------------------------------------\n", "Ran 3 tests in 0.005s\n", "\n", "OK\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Working on \n", "Working on testing\n", "Working on testing\n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 18, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from unittest import TestCase, mock, main\n", "\n", "\n", "class TestWorkerModule(TestCase):\n", "\n", " mockHelperInstance = mock.MagicMock()\n", " mockHelperInstance.get_path.return_value = 'testing'\n", "\n", " # using patch context manager\n", " def test_patch_class_context_manager(self):\n", " with mock.patch('worker.Helper',\n", " return_value=self.mockHelperInstance) as MockHelper:\n", " worker = Worker()\n", " MockHelper.assert_called_once_with('db')\n", " self.assertEqual(worker.work(), 'testing')\n", "\n", " # using patch class decorator\n", " @mock.patch('worker.Helper', return_value=mockHelperInstance)\n", " def test_patch_class_decorator(self, mock_helper):\n", " worker = Worker()\n", " mock_helper.assert_called_once_with('db')\n", " self.assertEqual(worker.work(), 'testing')\n", "\n", "main(argv=[''], verbosity=2, exit=False)" ] }, { "cell_type": "markdown", "id": "aa6a27bb-5489-458b-add6-1b6a36747deb", "metadata": {}, "source": [ "**Important**\n", "\n", "It is important to distinguish between **mocking a class** and \n", "**mocking the instance of a class**. Sometimes, it is necessary to mock the\n", "entire class, sometimes it might be sufficient to mock only an instance from\n", "that class.\n", "\n", "Mocking the entire class allows for \n", "\n", " * checking, in what way that class has been initialized\n", " * instantiating multiple instances from it, which return a Mock object and\n", " raises the call count\n", "\n", "For the above ``worker.py`` module, both these tests pass:" ] }, { "cell_type": "code", "execution_count": 19, "id": "e2e5e9b9-db0f-43eb-9a95-49993bc18901", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "test_using_decorator (__main__.TestWorkMockingModule.test_using_decorator) ... ok\n", "test_get_path (__main__.TestWorkerModule.test_get_path) ... ok\n", "test_initialization (__main__.TestWorkerModule.test_initialization) ... ok\n", "\n", "----------------------------------------------------------------------\n", "Ran 3 tests in 0.005s\n", "\n", "OK\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Working on \n", "Working on testing\n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 19, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from unittest import TestCase, mock, main\n", "\n", "mockHelper = mock.MagicMock()\n", "\n", "\n", "class TestWorkerModule(TestCase):\n", "\n", " def setUp(self):\n", " mockHelper.reset_mock()\n", " self.mock_helper_instance = mockHelper.return_value\n", "\n", " @mock.patch('worker.Helper', new=mockHelper)\n", " def test_initialization(self):\n", " Worker()\n", " mockHelper.assert_called_once_with('db')\n", "\n", " @mock.patch('worker.Helper', new=mockHelper)\n", " def test_get_path(self):\n", " self.mock_helper_instance.get_path.return_value = 'testing'\n", " worker = Worker()\n", " self.assertEqual(worker.work(), 'testing')\n", "\n", "main(argv=[''], verbosity=2, exit=False)" ] }, { "metadata": {}, "cell_type": "markdown", "source": [ "#### Speccing\n", "\n", "The danger with mocks is that a ``Mock`` object returns a new ``MagicMock`` object to\n", "basically every attribute or method it is queried for, no matter if that attribute or\n", "method actually exists. If the mocked class changes signatures or an attribute, the \n", "``Mock``object will still return the requested \"thing\" as another Mock, simply said\n", "\n", " The Mock does not know about the object it is mocking.\n", "\n", "To prevent this *speccing* can be added to a Mock, which makes it behave as the object\n", "being mocked. The easiest to use is ``autospec=True`` option.\n", "\n", "Example:\n", "\n", "Assuming the ``Helper`` class changed its ``get_path()`` method to ``get_folder()``, but the\n", "call is **not** changed in the ``Worker`` class:" ], "id": "e155ef8abe2eb53a" }, { "cell_type": "code", "execution_count": null, "id": "31e9ca53-e7df-453d-9b2f-16dfbab15259", "metadata": {}, "outputs": [], "source": [ "import os\n", "\n", "\n", "class Helper:\n", "\n", " def __init__(self, path):\n", " self.path = path\n", "\n", " def get_folder(self):\n", " base_path = os.getcwd()\n", " return os.path.join(base_path, self.path)\n", "\n", "\n", "class Worker:\n", "\n", " def __init__(self):\n", " self.helper = Helper('db')\n", "\n", " def work(self):\n", " path = self.helper.get_path()\n", " print(f'Working on {path}')\n", " return path" ] }, { "cell_type": "markdown", "id": "80c45cd8-2ddb-41ce-b8b0-ce0da0b08d0e", "metadata": {}, "source": [ "When running the previous test without the ``speccing`` option, this would pass, as we mock\n", "the ``get_path`` method for the Mock instance although the original class does not contain\n", "this method anymore.\n", "\n", "But with the ``autospec=True`` method, the Mock``get_path()`` method, the Mock object checks\n", "for consistence with the original object. So" ] }, { "cell_type": "code", "execution_count": 25, "id": "fbaf5d97-cdc5-45b6-89cf-b953d4d74249", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "======================================================================\n", "ERROR: test_get_path (__main__.TestWorkerModuleRaisesError.test_get_path)\n", "----------------------------------------------------------------------\n", "Traceback (most recent call last):\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/unittest/mock.py\", line 1390, in patched\n", " with self.decoration_helper(patched,\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py\", line 137, in __enter__\n", " return next(self.gen)\n", " ^^^^^^^^^^^^^^\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/unittest/mock.py\", line 1372, in decoration_helper\n", " arg = exit_stack.enter_context(patching)\n", " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py\", line 526, in enter_context\n", " result = _enter(cm)\n", " ^^^^^^^^^^\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/unittest/mock.py\", line 1539, in __enter__\n", " raise TypeError(\n", "TypeError: autospec creates the mock for you. Can't specify autospec and new.\n", "\n", "======================================================================\n", "FAIL: test_initialization (__main__.TestWorkerModule.test_initialization)\n", "----------------------------------------------------------------------\n", "Traceback (most recent call last):\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/unittest/mock.py\", line 1393, in patched\n", " return func(*newargs, **newkeywargs)\n", " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n", " File \"/var/folders/fr/qctyd7d96dn4qrkwypwgr1pr0000gn/T/ipykernel_8439/255302047.py\", line 16, in test_initialization\n", " mockHelper.assert_called_once_with('db')\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/unittest/mock.py\", line 958, in assert_called_once_with\n", " raise AssertionError(msg)\n", "AssertionError: Expected 'mock' to be called once. Called 0 times.\n", "\n", "----------------------------------------------------------------------\n", "Ran 4 tests in 0.003s\n", "\n", "FAILED (failures=1, errors=1)\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Working on \n", "Working on testing\n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 25, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from unittest import TestCase, mock, main\n", "\n", "mockHelper = mock.MagicMock()\n", "\n", "\n", "class TestWorkerModuleRaisesError(TestCase):\n", " @mock.patch('worker.Helper', new=mockHelper, autospec=True)\n", " def test_get_path(self):\n", " self.mock_helper_instance.get_path.return_value = 'testing'\n", " worker = Worker()\n", " self.assertEqual(worker.work(), 'testing')\n", "\n", "main(argv=[''], verbosity=0, exit=False)" ] }, { "cell_type": "markdown", "id": "70d0f858-5fae-4e21-924e-d64204a294df", "metadata": {}, "source": [ "raise an ``AttributeError``:\n", "\n", " AttributeError: Mock object has no attribute 'get_path'\n", "\n", "#### Partial class mocking\n", "\n", "A class can also be partially mocked by using ``mock.patch.object``:" ] }, { "cell_type": "code", "execution_count": 26, "id": "8cf6bb87-0ab3-4694-80b2-991612c3e4d2", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "test_using_decorator (__main__.TestWorkMockingModule.test_using_decorator) ... ok\n", "test_partial_patching_decorator (__main__.TestWorkerModule.test_partial_patching_decorator) ... ok\n", "test_get_path (__main__.TestWorkerModuleRaisesError.test_get_path) ... ERROR\n", "\n", "======================================================================\n", "ERROR: test_get_path (__main__.TestWorkerModuleRaisesError.test_get_path)\n", "----------------------------------------------------------------------\n", "Traceback (most recent call last):\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/unittest/mock.py\", line 1390, in patched\n", " with self.decoration_helper(patched,\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py\", line 137, in __enter__\n", " return next(self.gen)\n", " ^^^^^^^^^^^^^^\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/unittest/mock.py\", line 1372, in decoration_helper\n", " arg = exit_stack.enter_context(patching)\n", " ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/contextlib.py\", line 526, in enter_context\n", " result = _enter(cm)\n", " ^^^^^^^^^^\n", " File \"/usr/local/Cellar/python@3.12/3.12.5/Frameworks/Python.framework/Versions/3.12/lib/python3.12/unittest/mock.py\", line 1539, in __enter__\n", " raise TypeError(\n", "TypeError: autospec creates the mock for you. Can't specify autospec and new.\n", "\n", "----------------------------------------------------------------------\n", "Ran 3 tests in 0.005s\n", "\n", "FAILED (errors=1)\n" ] }, { "name": "stdout", "output_type": "stream", "text": [ "Working on \n", "Working on testing\n" ] }, { "data": { "text/plain": [ "" ] }, "execution_count": 26, "metadata": {}, "output_type": "execute_result" } ], "source": [ "from unittest import TestCase, mock, main\n", "from worker import Worker, Helper\n", "\n", "\n", "class TestWorkerModule(TestCase):\n", "\n", " def test_partial_patching_decorator(self):\n", " with mock.patch.object(Helper, 'get_path', return_value='testing'):\n", " worker = Worker()\n", " self.assertEqual(worker.helper.path, 'db')\n", " self.assertEqual(worker.work(), 'testing')\n", "\n", " @mock.patch.object(Helper, 'get_path', return_value='testing')\n", " def test_partial_patching_decorator(self, mocked_helper_get_path):\n", " worker = Worker()\n", " self.assertEqual(worker.helper.path, 'db')\n", " self.assertEqual(worker.work(), 'testing')\n", " mocked_helper_get_path.assert_called_once()\n", "\n", "main(argv=[''], verbosity=2, exit=False)" ] }, { "cell_type": "markdown", "id": "e29ec97a187f04b8", "metadata": { "jp-MarkdownHeadingCollapsed": true }, "source": [ "# unittest.mock.patch\n", "## Patch an import module\n", "``unittest.mock`` is able to patch imports in the module under test using the\n", "[patch](https://docs.python.org/3/library/unittest.mock.html#patch) function.\n", "\n", "``patch`` will intercept import statements identified by a string and return\n", "a Mock instance you can preconfigure using the techniques from ``unittest.Mock`` (see above).\n", "\n", "Module under test (``work.py``):\n", "\n", "```python\n", "import os\n", "\n", "def work_on():\n", " path = os.getcwd()\n", " print(f'Working on {path}')\n", " return path\n", "```\n", "\n", "The project module imports ``os`` and uses its ``getcwd()`` method , which\n", "we want to mock in our test.\n", "\n", "### Patch using a context manager (``with`` statement)\n", "\n", "When using the context manager ``patch``, the patch ends when the ``with`` statement ends.\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "from work import work_on\n", "\n", "class TestWorkMockingModule(TestCase):\n", "\n", " def test_using_context_manager(self):\n", " with mock.patch('work.os') as mocked_os:\n", " work_on()\n", " mocked_os.getcwd.assert_called_once()\n", "```\n", "\n", "We specifically patch the ``work.os`` module, not ``os``, as this\n", "would patch ``os`` for all modules, not just for ``work.py``.\n", "\n", "* The test module imports the project module's method ``work_on()``\n", "* At the beginning of the test, the ``work.os`` module (which is the ``os``\n", " module in our ``work.py`` module) is patched with a [MagickMock][1] object\n", " (here called ``mocked_os``)\n", "* When the ``work_on()`` method is called afterward, that ``MagicMock`` object is\n", " called instead of the original module (``os``) - as no return value is defined\n", " ``work_on()`` returns another MagicMock object - as we are not testing the\n", " correctness of the return value here, only checking if the ``mocked_os`` executed\n", " the ``getcwd()``method, we do not care about the return value\n", "\n", "The ``as`` statement in the decorator is optional. You may as well not mock the\n", "entire ``work.os`` module, but rather define a return value for the single\n", "function, that is called (``getcwd()``):\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "from work import work_on\n", "\n", "class TestWorkMockingModule(TestCase):\n", " def test_using_return_value(self):\n", " with mock.patch('work.os.getcwd', return_value='testing'):\n", " assert work_on() == 'testing'\n", "```\n", "\n", "### Patch using the decorator (@patch)\n", "Using the ``@patch`` decorator allows to inject a mock into the test function.\n", "The patch is available for the entire function.\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "\n", "from work import work_on\n", "\n", "class TestWorkMockingModule(TestCase):\n", "\n", " @mock.patch('work.os')\n", " def test_using_decorator(self, mocked_os):\n", " work_on()\n", " mocked_os.getcwd.assert_called_once()\n", "```\n", "\n", "Here the ``work.os`` module is replaced with a MagicMock object.\n", "\n", "[1]: https://docs.python.org/3/library/unittest.mock.html?highlight=magicmock#unittest.mock.MagicMock\n", "\n", "## Patching classes\n", "To test project classes that interact with other project classes in isolation,\n", "the other project classes must be mocked (in order to determine test failure\n", "to a specific class and method, not by any of its dependency classes).\n", "\n", "Example:\n", "\n", "### Project module (worker.py)\n", "\n", "In this module, the `Worker` class needs to be tested for these two things:\n", "\n", "- the `Worker` calls `Helper` with `db`\n", "- the `Worker` returns the expected path supplied by `Helper `\n", "\n", "```python\n", "import os\n", "\n", "\n", "class Helper:\n", "\n", " def __init__(self, path):\n", " self.path = path\n", "\n", " def get_path(self):\n", " base_path = os.getcwd()\n", " return os.path.join(base_path, self.path)\n", "\n", "\n", "class Worker:\n", "\n", " def __init__(self):\n", " self.helper = Helper('db')\n", "\n", " def work(self):\n", " path = self.helper.get_path()\n", " print(f'Working on {path}')\n", " return path\n", "```\n", "\n", "### Test module (test_worker.py)\n", "\n", "#### Patch entire class\n", "\n", "To test this, the `Worker` needs to be isolated from `Helper`,\n", "so that failures can be associated with the `Worker` class only.\n", "\n", "Consequently, the entire `Helper` class must be patched:\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "from worker import Worker\n", "\n", "\n", "class TestWorkerModule(TestCase):\n", "\n", " def test_patching_class(self):\n", " with mock.patch('worker.Helper') as MockHelper:\n", " MockHelper.return_value.get_path.return_value = 'testing'\n", " worker = Worker()\n", " MockHelper.assert_called_once_with('db')\n", " self.assertEqual(worker.work(), 'testing')\n", "```\n", "\n", "Note the double ``return_value`` in the example, simply using \n", "``MockHelper.get_path.return_value`` would not work since in the code\n", "we call get_path on an instance, not the class itself.\n", "\n", "Alternatively, we can preconfigure the ``MockHelper`` instance and\n", "assign it as the return value of the ``worker.Helper`` call. Below,\n", "shown for both the context manager and the decorator way:\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "from worker import Worker\n", "\n", "\n", "class TestWorkerModule(TestCase):\n", "\n", " mockHelperInstance = mock.MagicMock()\n", " mockHelperInstance.get_path.return_value = 'testing'\n", "\n", " # using patch context manager\n", " def test_patch_class_context_manager(self):\n", " with mock.patch('worker.Helper',\n", " return_value=self.mockHelperInstance) as MockHelper:\n", " worker = Worker()\n", " MockHelper.assert_called_once_with('db')\n", " self.assertEqual(worker.work(), 'testing')\n", "\n", " # using patch class decorator\n", " @mock.patch('worker.Helper', return_value=mockHelperInstance)\n", " def test_patch_class_decorator(self, mock_helper):\n", " worker = Worker()\n", " mock_helper.assert_called_once_with('db')\n", " self.assertEqual(worker.work(), 'testing')\n", "```\n", "\n", "**Important**\n", "\n", "It is important to distinguish between **mocking a class** and \n", "**mocking the instance of a class**. Sometimes, it is necessary to mock the\n", "entire class, sometimes it might be sufficient to mock only an instance from\n", "that class.\n", "\n", "Mocking the entire class allows for \n", "\n", " * checking, in what way that class has been initialized\n", " * instantiating multiple instances from it, which return a Mock object and\n", " raises the call count\n", "\n", "For the above ``worker.py`` module, both these tests pass:\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "from worker import Worker\n", "\n", "mockHelper = mock.MagicMock()\n", "\n", "\n", "class TestWorkerModule(TestCase):\n", "\n", " def setUp(self):\n", " mockHelper.reset_mock()\n", " self.mock_helper_instance = mockHelper.return_value\n", "\n", " @mock.patch('worker.Helper', new=mockHelper)\n", " def test_initialization(self):\n", " Worker()\n", " mockHelper.assert_called_once_with('db')\n", "\n", " @mock.patch('worker.Helper', new=mockHelper)\n", " def test_get_path(self):\n", " self.mock_helper_instance.get_path.return_value = 'testing'\n", " worker = Worker()\n", " self.assertEqual(worker.work(), 'testing')\n", "```\n", "\n", "#### Speccing\n", "\n", "The danger with mocks is that a ``Mock`` object returns a new ``MagicMock`` object to\n", "basically every attribute or method it is queried for, no matter if that attribute or\n", "method actually exists. If the mocked class changes signatures or an attribute, the \n", "``Mock``object will still return the requested \"thing\" as another Mock, simply said\n", "\n", " The Mock does not know about the object it is mocking.\n", "\n", "To prevent this *speccing* can be added to a Mock, which makes it behave as the object\n", "being mocked. The easiest to use is ``autospec=True`` option.\n", "\n", "Example:\n", "\n", "Assuming the ``Helper`` class changed its ``get_path()`` method to ``get_folder()``, but the\n", "call is **not** changed in the ``Worker`` class:\n", "\n", "```python\n", "import os\n", "\n", "\n", "class Helper:\n", "\n", " def __init__(self, path):\n", " self.path = path\n", "\n", " def get_folder(self):\n", " base_path = os.getcwd()\n", " return os.path.join(base_path, self.path)\n", "\n", "\n", "class Worker:\n", "\n", " def __init__(self):\n", " self.helper = Helper('db')\n", "\n", " def work(self):\n", " path = self.helper.get_path()\n", " print(f'Working on {path}')\n", " return path\n", "```\n", "\n", "When running the previous test without the ``speccing`` option, this would pass, as we mock\n", "the ``get_path`` method for the Mock instance although the original class does not contain\n", "this method anymore.\n", "\n", "But with the ``autospec=True`` method, the Mock``get_path()`` method, the Mock object checks\n", "for consistence with the original object. So\n", "\n", "```python\n", " @mock.patch('worker.Helper', new=mockHelper, autospec=True)\n", " def test_get_path(self):\n", " self.mock_helper_instance.get_path.return_value = 'testing'\n", " worker = Worker()\n", " self.assertEqual(worker.work(), 'testing')\n", "```\n", "\n", "raise an ``AttributeError``:\n", "\n", " AttributeError: Mock object has no attribute 'get_path'\n", "\n", "#### Partial class mocking\n", "\n", "A class can also be partially mocked by using ``mock.patch.object``:\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "from worker import Worker, Helper\n", "\n", "\n", "class TestWorkerModule(TestCase):\n", "\n", " def test_partial_patching_decorator(self):\n", " with mock.patch.object(Helper, 'get_path', return_value='testing'):\n", " worker = Worker()\n", " self.assertEqual(worker.helper.path, 'db')\n", " self.assertEqual(worker.work(), 'testing')\n", "\n", " @mock.patch.object(Helper, 'get_path', return_value='testing')\n", " def test_partial_patching_decorator(self, mocked_helper_get_path):\n", " worker = Worker()\n", " self.assertEqual(worker.helper.path, 'db')\n", " self.assertEqual(worker.work(), 'testing')\n", " mocked_helper_get_path.assert_called_once()\n", "```\n", "\n", "\n", "### Mocking built-in functions and environment variables\n", "\n", "Previously, the ``print()`` call in ``Worker.work()`` was neglected, though\n", "it might be important to test, if that call was made.\n", "\n", "Assuming this version of the ``work()`` method inside ``worker.py``:\n", "\n", "```python\n", "import os\n", "\n", "def work_on_env():\n", " path = os.path.join(os.getcwd(), os.environ['MY_VAR'])\n", " print(f'Working on {path}')\n", " return path\n", "```\n", "\n", "The path is concatenated by a ``MY_VAR`` environment variable and the\n", "``os.getcwd()`` return value. The test:\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "\n", "from worker import work_on_env\n", "\n", "class TestBuiltin(TestCase):\n", "\n", " def test_patch_dict_context_manager(self):\n", " with mock.patch('worker.print') as mock_print:\n", " with mock.patch('os.getcwd', return_value='/home/'):\n", " with mock.patch.dict('os.environ', {'MY_VAR': 'testing'}):\n", " self.assertEqual(work_on_env(), '/home/testing')\n", " mock_print.assert_called_once_with('Working on /home/testing')\n", "```\n", "\n", "* we patch the built-in ``print()`` function for the ``worker`` module as ``mock_print`` (patch on import)\n", "* we patch the ``worker.work_on_env()`` method assigning \"/home/\" as the return value\n", "* we patch the ``os.environ`` dictionary with a ``{'MY_VAR': 'testing'}`` dictionary\n", "\n", "Nesting multiple context managers can be prevented by using multiple patch decorators:\n", "\n", "```python\n", "from unittest import TestCase, mock\n", "\n", "from worker import work_on_env\n", "\n", "class TestBuiltin(TestCase):\n", " @mock.patch('os.getcwd', return_value='/home/')\n", " @mock.patch('worker.print')\n", " @mock.patch.dict('os.environ', {'MY_VAR': 'testing'})\n", " def test_patch_builtin_dict_decorators(self, mock_print, mock_getcwd):\n", " self.assertEqual(work_on_env(), '/home/testing')\n", " mock_print.assert_called_once_with('Working on /home/testing')\n", "```\n", "\n", "Note that the ``mock_getcwd`` must be passed into the test function, though it isn't used.\n", "\n", "\n", "--- skipped 'Mocking context managers' here, might document later ---\n", "\n", "*NEEDS TO BE CONTINUED...*" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.5" } }, "nbformat": 4, "nbformat_minor": 5 }