add updated monkeypatch examples

This commit is contained in:
Evan Kepner 2019-05-27 20:54:42 -04:00
parent ece774f0eb
commit 24c95c78e7
No known key found for this signature in database
GPG Key ID: 25535B2446B36F2F
2 changed files with 299 additions and 20 deletions

1
changelog/5315.doc.rst Normal file
View File

@ -0,0 +1 @@
Expand docs on mocking classes and dictionaries with ``monkeypatch``.

View File

@ -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