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