add updated monkeypatch examples
This commit is contained in:
		
							parent
							
								
									ece774f0eb
								
							
						
					
					
						commit
						24c95c78e7
					
				|  | @ -0,0 +1 @@ | ||||||
|  | Expand docs on mocking classes and dictionaries with ``monkeypatch``. | ||||||
|  | @ -8,46 +8,217 @@ Sometimes tests need to invoke functionality which depends | ||||||
| on global settings or which invokes code which cannot be easily | on global settings or which invokes code which cannot be easily | ||||||
| tested such as network access.  The ``monkeypatch`` fixture | tested such as network access.  The ``monkeypatch`` fixture | ||||||
| helps you to safely set/delete an attribute, dictionary item or | helps you to safely set/delete an attribute, dictionary item or | ||||||
| environment variable or to modify ``sys.path`` for importing. | environment variable, or to modify ``sys.path`` for importing. | ||||||
|  | 
 | ||||||
|  | The ``monkeypatch`` fixture provides these helper methods for safely patching and mocking | ||||||
|  | functionality in tests: | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     monkeypatch.setattr(obj, name, value, raising=True) | ||||||
|  |     monkeypatch.delattr(obj, name, raising=True) | ||||||
|  |     monkeypatch.setitem(mapping, name, value) | ||||||
|  |     monkeypatch.delitem(obj, name, raising=True) | ||||||
|  |     monkeypatch.setenv(name, value, prepend=False) | ||||||
|  |     monkeypatch.delenv(name, raising=True) | ||||||
|  |     monkeypatch.syspath_prepend(path) | ||||||
|  |     monkeypatch.chdir(path) | ||||||
|  | 
 | ||||||
|  | All modifications will be undone after the requesting | ||||||
|  | test function or fixture has finished. The ``raising`` | ||||||
|  | parameter determines if a ``KeyError`` or ``AttributeError`` | ||||||
|  | will be raised if the target of the set/deletion operation does not exist. | ||||||
|  | 
 | ||||||
|  | Consider the following scenarios: | ||||||
|  | 
 | ||||||
|  | 1. Modifying the behavior of a function or the property of a class for a test e.g. | ||||||
|  | there is an API call or database connection you will not make for a test but you know | ||||||
|  | what the expected output should be. Use :py:meth:`monkeypatch.setattr` to patch the | ||||||
|  | function or property with your desired testing behavior. This can include your own functions. | ||||||
|  | Use :py:meth:`monkeypatch.delattr` to remove the function or property for the test. | ||||||
|  | 
 | ||||||
|  | 2. Modifying the values of dictionaries e.g. you have a global configuration that | ||||||
|  | you want to modify for certain test cases. Use :py:meth:`monkeypatch.setitem` to patch the | ||||||
|  | dictionary for the test. :py:meth:`monkeypatch.delitem` can be used to remove items. | ||||||
|  | 
 | ||||||
|  | 3. Modifying environment variables for a test e.g. to test program behavior if an | ||||||
|  | environment variable is missing, or to set multiple values to a known variable. | ||||||
|  | :py:meth:`monkeypatch.setenv` and :py:meth:`monkeypatch.delenv` can be used for | ||||||
|  | these patches. | ||||||
|  | 
 | ||||||
|  | 4. Use :py:meth:`monkeypatch.syspath_prepend` to modify the system ``$PATH`` safely, and | ||||||
|  | :py:meth:`monkeypatch.chdir` to change the context of the current working directory | ||||||
|  | during a test. | ||||||
|  | 
 | ||||||
| See the `monkeypatch blog post`_ for some introduction material | See the `monkeypatch blog post`_ for some introduction material | ||||||
| and a discussion of its motivation. | and a discussion of its motivation. | ||||||
| 
 | 
 | ||||||
| .. _`monkeypatch blog post`: http://tetamap.wordpress.com/2009/03/03/monkeypatching-in-unit-tests-done-right/ | .. _`monkeypatch blog post`: http://tetamap.wordpress.com/2009/03/03/monkeypatching-in-unit-tests-done-right/ | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| Simple example: monkeypatching functions | Simple example: monkeypatching functions | ||||||
| ---------------------------------------- | ---------------------------------------- | ||||||
| 
 | 
 | ||||||
| If you want to pretend that ``os.expanduser`` returns a certain | Consider a scenario where you are working with user directories. In the context of | ||||||
| directory, you can use the :py:meth:`monkeypatch.setattr` method to | testing, you do not want your test to depend on the running user. ``monkeypatch`` | ||||||
| patch this function before calling into a function which uses it:: | can be used to patch functions dependent on the user to always return a | ||||||
|  | specific value. | ||||||
| 
 | 
 | ||||||
|     # content of test_module.py | In this example, :py:meth:`monkeypatch.setattr` is used to patch ``os.path.expanduser`` | ||||||
|  | so that the known testing string ``"/abc"`` is always used when the test is run. | ||||||
|  | This removes any dependency on the running user for testing purposes. | ||||||
|  | :py:meth:`monkeypatch.setattr` must be called before the function which will use | ||||||
|  | the patched function is called. | ||||||
|  | After the test function finishes the ``os.path.expanduser`` modification will be undone. | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of test_module.py with source code and the test | ||||||
|  |     # os.path is imported for reference in monkeypatch.setattr() | ||||||
|     import os.path |     import os.path | ||||||
|     def getssh(): # pseudo application code |  | ||||||
|         return os.path.join(os.path.expanduser("~admin"), '.ssh') |  | ||||||
| 
 | 
 | ||||||
|     def test_mytest(monkeypatch): | 
 | ||||||
|  |     def getssh(): | ||||||
|  |         """Simple function to return expanded homedir ssh path.""" | ||||||
|  |         return os.path.expanduser("~/.ssh") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     def test_getssh(monkeypatch): | ||||||
|  |         # mocked return function to replace os.path.expanduser | ||||||
|  |         # given a path, always return '/abc' | ||||||
|         def mockreturn(path): |         def mockreturn(path): | ||||||
|             return '/abc' |             return "/abc" | ||||||
|         monkeypatch.setattr(os.path, 'expanduser', mockreturn) | 
 | ||||||
|         x = getssh() |         # Application of the monkeypatch to replace os.path.expanduser | ||||||
|         assert x == '/abc/.ssh' |         # with the behavior of mockreturn defined above. | ||||||
|  |         monkeypatch.setattr(os.path, "expanduser", mockreturn) | ||||||
|  | 
 | ||||||
|  |         # Calling getssh() will use mockreturn in place of os.path.expanduser | ||||||
|  |         # for this test with the monkeypatch. | ||||||
|  |         x = getssh() | ||||||
|  |         assert x == "/abc/.ssh" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | Monkeypatching returned objects: building mock classes | ||||||
|  | ------------------------------------------------------ | ||||||
|  | 
 | ||||||
|  | :py:meth:`monkeypatch.setattr` can be used in conjunction with classes to mock returned | ||||||
|  | objects from functions instead of values. | ||||||
|  | Imagine a simple function to take an API url and return the json response. | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of app.py, a simple API retrieval example | ||||||
|  |     import requests | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     def get_json(url): | ||||||
|  |         """Takes a URL, and returns the JSON.""" | ||||||
|  |         r = requests.get(url) | ||||||
|  |         return r.json() | ||||||
|  | 
 | ||||||
|  | We need to mock ``r``, the returned response object for testing purposes. | ||||||
|  | The mock of ``r`` needs a ``.json()`` method which returns a dictionary. | ||||||
|  | This can be done in our test file by defining a class to represent ``r``. | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of test_app.py, a simple test for our API retrieval | ||||||
|  |     # import requests for the purposes of monkeypatching | ||||||
|  |     import requests | ||||||
|  | 
 | ||||||
|  |     # our app.py that includes the get_json() function | ||||||
|  |     # this is the previous code block example | ||||||
|  |     import app | ||||||
|  | 
 | ||||||
|  |     # custom class to be the mock return value | ||||||
|  |     # will override the requests.Response returned from requests.get | ||||||
|  |     class MockResponse: | ||||||
|  | 
 | ||||||
|  |         # mock json() method always returns a specific testing dictionary | ||||||
|  |         @staticmethod | ||||||
|  |         def json(): | ||||||
|  |             return {"mock_key": "mock_response"} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     def test_get_json(monkeypatch): | ||||||
|  | 
 | ||||||
|  |         # Any arguments may be passed and mock_get() will always return our | ||||||
|  |         # mocked object, which only has the .json() method. | ||||||
|  |         def mock_get(*args, **kwargs): | ||||||
|  |             return MockResponse() | ||||||
|  | 
 | ||||||
|  |         # apply the monkeypatch for requests.get to mock_get | ||||||
|  |         monkeypatch.setattr(requests, "get", mock_get) | ||||||
|  | 
 | ||||||
|  |         # app.get_json, which contains requests.get, uses the monkeypatch | ||||||
|  |         result = app.get_json("https://fakeurl") | ||||||
|  |         assert result["mock_key"] == "mock_response" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ``monkeypatch`` applies the mock for ``requests.get`` with our ``mock_get`` function. | ||||||
|  | The ``mock_get`` function returns an instance of the ``MockResponse`` class, which | ||||||
|  | has a ``json()`` method defined to return a known testing dictionary and does not | ||||||
|  | require any outside API connection. | ||||||
|  | 
 | ||||||
|  | You can build the ``MockResponse`` class with the appropriate degree of complexity for | ||||||
|  | the scenario you are testing. For instance, it could include an ``ok`` property that | ||||||
|  | always returns ``True``, or return different values from the ``json()`` mocked method | ||||||
|  | based on input strings. | ||||||
|  | 
 | ||||||
|  | This mock can be shared across tests using a ``fixture``: | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of test_app.py, a simple test for our API retrieval | ||||||
|  |     import pytest | ||||||
|  |     import requests | ||||||
|  | 
 | ||||||
|  |     # app.py that includes the get_json() function | ||||||
|  |     import app | ||||||
|  | 
 | ||||||
|  |     # custom class to be the mock return value of requests.get() | ||||||
|  |     class MockResponse: | ||||||
|  |         @staticmethod | ||||||
|  |         def json(): | ||||||
|  |             return {"mock_key": "mock_response"} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     # monkeypatched requests.get moved to a fixture | ||||||
|  |     @pytest.fixture | ||||||
|  |     def mock_response(monkeypatch): | ||||||
|  |         """Requests.get() mocked to return {'mock_key':'mock_response'}.""" | ||||||
|  | 
 | ||||||
|  |         def mock_get(*args, **kwargs): | ||||||
|  |             return MockResponse() | ||||||
|  | 
 | ||||||
|  |         monkeypatch.setattr(requests, "get", mock_get) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     # notice our test uses the custom fixture instead of monkeypatch directly | ||||||
|  |     def test_get_json(mock_response): | ||||||
|  |         result = app.get_json("https://fakeurl") | ||||||
|  |         assert result["mock_key"] == "mock_response" | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | Furthermore, if the mock was designed to be applied to all tests, the ``fixture`` could | ||||||
|  | be moved to a ``conftest.py`` file and use the with ``autouse=True`` option. | ||||||
| 
 | 
 | ||||||
| Here our test function monkeypatches ``os.path.expanduser`` and |  | ||||||
| then calls into a function that calls it.  After the test function |  | ||||||
| finishes the ``os.path.expanduser`` modification will be undone. |  | ||||||
| 
 | 
 | ||||||
| Global patch example: preventing "requests" from remote operations | Global patch example: preventing "requests" from remote operations | ||||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------ | ||||||
| 
 | 
 | ||||||
| If you want to prevent the "requests" library from performing http | If you want to prevent the "requests" library from performing http | ||||||
| requests in all your tests, you can do:: | requests in all your tests, you can do: | ||||||
| 
 | 
 | ||||||
|     # content of conftest.py | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of conftest.py | ||||||
|     import pytest |     import pytest | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|     @pytest.fixture(autouse=True) |     @pytest.fixture(autouse=True) | ||||||
|     def no_requests(monkeypatch): |     def no_requests(monkeypatch): | ||||||
|  |         """Remove requests.sessions.Session.request for all tests.""" | ||||||
|         monkeypatch.delattr("requests.sessions.Session.request") |         monkeypatch.delattr("requests.sessions.Session.request") | ||||||
| 
 | 
 | ||||||
| This autouse fixture will be executed for each test function and it | This autouse fixture will be executed for each test function and it | ||||||
|  | @ -85,7 +256,7 @@ Monkeypatching environment variables | ||||||
| ------------------------------------ | ------------------------------------ | ||||||
| 
 | 
 | ||||||
| If you are working with environment variables you often need to safely change the values | If you are working with environment variables you often need to safely change the values | ||||||
| or delete them from the system for testing purposes. ``Monkeypatch`` provides a mechanism | or delete them from the system for testing purposes. ``monkeypatch`` provides a mechanism | ||||||
| to do this using the ``setenv`` and ``delenv`` method. Our example code to test: | to do this using the ``setenv`` and ``delenv`` method. Our example code to test: | ||||||
| 
 | 
 | ||||||
| .. code-block:: python | .. code-block:: python | ||||||
|  | @ -131,6 +302,7 @@ This behavior can be moved into ``fixture`` structures and shared across tests: | ||||||
| 
 | 
 | ||||||
| .. code-block:: python | .. code-block:: python | ||||||
| 
 | 
 | ||||||
|  |     # contents of our test file e.g. test_code.py | ||||||
|     import pytest |     import pytest | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | @ -144,7 +316,7 @@ This behavior can be moved into ``fixture`` structures and shared across tests: | ||||||
|         monkeypatch.delenv("USER", raising=False) |         monkeypatch.delenv("USER", raising=False) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|     # Notice the tests reference the fixtures for mocks |     # notice the tests reference the fixtures for mocks | ||||||
|     def test_upper_to_lower(mock_env_user): |     def test_upper_to_lower(mock_env_user): | ||||||
|         assert get_os_user_lower() == "testinguser" |         assert get_os_user_lower() == "testinguser" | ||||||
| 
 | 
 | ||||||
|  | @ -154,6 +326,112 @@ This behavior can be moved into ``fixture`` structures and shared across tests: | ||||||
|             _ = get_os_user_lower() |             _ = get_os_user_lower() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | Monkeypatching dictionaries | ||||||
|  | --------------------------- | ||||||
|  | 
 | ||||||
|  | :py:meth:`monkeypatch.setitem` can be used to safely set the values of dictionaries | ||||||
|  | to specific values during tests. Take this simplified connection string example: | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of app.py to generate a simple connection string | ||||||
|  |     DEFAULT_CONFIG = {"user": "user1", "database": "db1"} | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     def create_connection_string(config=None): | ||||||
|  |         """Creates a connection string from input or defaults.""" | ||||||
|  |         config = config or DEFAULT_CONFIG | ||||||
|  |         return f"User Id={config['user']}; Location={config['database']};" | ||||||
|  | 
 | ||||||
|  | For testing purposes we can patch the ``DEFAULT_CONFIG`` dictionary to specific values. | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of test_app.py | ||||||
|  |     # app.py with the connection string function (prior code block) | ||||||
|  |     import app | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     def test_connection(monkeypatch): | ||||||
|  | 
 | ||||||
|  |         # Patch the values of DEFAULT_CONFIG to specific | ||||||
|  |         # testing values only for this test. | ||||||
|  |         monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user") | ||||||
|  |         monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db") | ||||||
|  | 
 | ||||||
|  |         # expected result based on the mocks | ||||||
|  |         expected = "User Id=test_user; Location=test_db;" | ||||||
|  | 
 | ||||||
|  |         # the test uses the monkeypatched dictionary settings | ||||||
|  |         result = app.create_connection_string() | ||||||
|  |         assert result == expected | ||||||
|  | 
 | ||||||
|  | You can use the :py:meth:`monkeypatch.delitem` to remove values. | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of test_app.py | ||||||
|  |     import pytest | ||||||
|  | 
 | ||||||
|  |     # app.py with the connection string function | ||||||
|  |     import app | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     def test_missing_user(monkeypatch): | ||||||
|  | 
 | ||||||
|  |         # patch the DEFAULT_CONFIG t be missing the 'user' key | ||||||
|  |         monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False) | ||||||
|  | 
 | ||||||
|  |         # Key error expected because a config is not passed, and the | ||||||
|  |         # default is now missing the 'user' entry. | ||||||
|  |         with pytest.raises(KeyError): | ||||||
|  |             _ = app.create_connection_string() | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | The modularity of fixtures gives you the flexibility to define | ||||||
|  | separate fixtures for each potential mock and reference them in the needed tests. | ||||||
|  | 
 | ||||||
|  | .. code-block:: python | ||||||
|  | 
 | ||||||
|  |     # contents of test_app.py | ||||||
|  |     import pytest | ||||||
|  | 
 | ||||||
|  |     # app.py with the connection string function | ||||||
|  |     import app | ||||||
|  | 
 | ||||||
|  |     # all of the mocks are moved into separated fixtures | ||||||
|  |     @pytest.fixture | ||||||
|  |     def mock_test_user(monkeypatch): | ||||||
|  |         """Set the DEFAULT_CONFIG user to test_user.""" | ||||||
|  |         monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     @pytest.fixture | ||||||
|  |     def mock_test_database(monkeypatch): | ||||||
|  |         """Set the DEFAULT_CONFIG database to test_db.""" | ||||||
|  |         monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db") | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     @pytest.fixture | ||||||
|  |     def mock_missing_default_user(monkeypatch): | ||||||
|  |         """Remove the user key from DEFAULT_CONFIG""" | ||||||
|  |         monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     # tests reference only the fixture mocks that are needed | ||||||
|  |     def test_connection(mock_test_user, mock_test_database): | ||||||
|  | 
 | ||||||
|  |         expected = "User Id=test_user; Location=test_db;" | ||||||
|  | 
 | ||||||
|  |         result = app.create_connection_string() | ||||||
|  |         assert result == expected | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |     def test_missing_user(mock_missing_default_user): | ||||||
|  | 
 | ||||||
|  |         with pytest.raises(KeyError): | ||||||
|  |             _ = app.create_connection_string() | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| .. currentmodule:: _pytest.monkeypatch | .. currentmodule:: _pytest.monkeypatch | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
		Reference in New Issue