Documentation: expansion of Monkeypatch to include mocked classes and dictionaries (#5315)
Documentation: expansion of Monkeypatch to include mocked classes and dictionaries
This commit is contained in:
commit
2a3d643bdf
|
@ -0,0 +1 @@
|
|||
Expand docs on mocking classes and dictionaries with ``monkeypatch``.
|
|
@ -8,46 +8,215 @@ Sometimes tests need to invoke functionality which depends
|
|||
on global settings or which invokes code which cannot be easily
|
||||
tested such as network access. The ``monkeypatch`` fixture
|
||||
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
|
||||
and a discussion of its motivation.
|
||||
|
||||
.. _`monkeypatch blog post`: http://tetamap.wordpress.com/2009/03/03/monkeypatching-in-unit-tests-done-right/
|
||||
|
||||
|
||||
Simple example: monkeypatching functions
|
||||
----------------------------------------
|
||||
|
||||
If you want to pretend that ``os.expanduser`` returns a certain
|
||||
directory, you can use the :py:meth:`monkeypatch.setattr` method to
|
||||
patch this function before calling into a function which uses it::
|
||||
Consider a scenario where you are working with user directories. In the context of
|
||||
testing, you do not want your test to depend on the running user. ``monkeypatch``
|
||||
can be used to patch functions dependent on the user to always return a
|
||||
specific value.
|
||||
|
||||
# content of test_module.py
|
||||
import os.path
|
||||
def getssh(): # pseudo application code
|
||||
return os.path.join(os.path.expanduser("~admin"), '.ssh')
|
||||
In this example, :py:meth:`monkeypatch.setattr` is used to patch ``Path.home``
|
||||
so that the known testing path ``Path("/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 ``Path.home`` modification will be undone.
|
||||
|
||||
def test_mytest(monkeypatch):
|
||||
def mockreturn(path):
|
||||
return '/abc'
|
||||
monkeypatch.setattr(os.path, 'expanduser', mockreturn)
|
||||
.. code-block:: python
|
||||
|
||||
# contents of test_module.py with source code and the test
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def getssh():
|
||||
"""Simple function to return expanded homedir ssh path."""
|
||||
return Path.home() / ".ssh"
|
||||
|
||||
|
||||
def test_getssh(monkeypatch):
|
||||
# mocked return function to replace Path.home
|
||||
# always return '/abc'
|
||||
def mockreturn():
|
||||
return Path("/abc")
|
||||
|
||||
# Application of the monkeypatch to replace Path.home
|
||||
# with the behavior of mockreturn defined above.
|
||||
monkeypatch.setattr(Path, "home", mockreturn)
|
||||
|
||||
# Calling getssh() will use mockreturn in place of Path.home
|
||||
# for this test with the monkeypatch.
|
||||
x = getssh()
|
||||
assert x == '/abc/.ssh'
|
||||
assert x == Path("/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
|
||||
------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_requests(monkeypatch):
|
||||
"""Remove requests.sessions.Session.request for all tests."""
|
||||
monkeypatch.delattr("requests.sessions.Session.request")
|
||||
|
||||
This autouse fixture will be executed for each test function and it
|
||||
|
@ -85,7 +254,7 @@ Monkeypatching environment variables
|
|||
------------------------------------
|
||||
|
||||
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:
|
||||
|
||||
.. code-block:: python
|
||||
|
@ -131,6 +300,7 @@ This behavior can be moved into ``fixture`` structures and shared across tests:
|
|||
|
||||
.. code-block:: python
|
||||
|
||||
# contents of our test file e.g. test_code.py
|
||||
import pytest
|
||||
|
||||
|
||||
|
@ -144,7 +314,7 @@ This behavior can be moved into ``fixture`` structures and shared across tests:
|
|||
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):
|
||||
assert get_os_user_lower() == "testinguser"
|
||||
|
||||
|
@ -154,6 +324,112 @@ This behavior can be moved into ``fixture`` structures and shared across tests:
|
|||
_ = 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
|
||||
|
||||
|
|
Loading…
Reference in New Issue