Skip to content

Commit 2a3d643

Browse files
authored
Documentation: expansion of Monkeypatch to include mocked classes and dictionaries (#5315)
Documentation: expansion of Monkeypatch to include mocked classes and dictionaries
2 parents 2cfd9c2 + 2dfbed1 commit 2a3d643

File tree

2 files changed

+298
-21
lines changed

2 files changed

+298
-21
lines changed

changelog/5315.doc.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Expand docs on mocking classes and dictionaries with ``monkeypatch``.

doc/en/monkeypatch.rst

Lines changed: 297 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,46 +8,215 @@ Sometimes tests need to invoke functionality which depends
88
on global settings or which invokes code which cannot be easily
99
tested such as network access. The ``monkeypatch`` fixture
1010
helps you to safely set/delete an attribute, dictionary item or
11-
environment variable or to modify ``sys.path`` for importing.
11+
environment variable, or to modify ``sys.path`` for importing.
12+
13+
The ``monkeypatch`` fixture provides these helper methods for safely patching and mocking
14+
functionality in tests:
15+
16+
.. code-block:: python
17+
18+
monkeypatch.setattr(obj, name, value, raising=True)
19+
monkeypatch.delattr(obj, name, raising=True)
20+
monkeypatch.setitem(mapping, name, value)
21+
monkeypatch.delitem(obj, name, raising=True)
22+
monkeypatch.setenv(name, value, prepend=False)
23+
monkeypatch.delenv(name, raising=True)
24+
monkeypatch.syspath_prepend(path)
25+
monkeypatch.chdir(path)
26+
27+
All modifications will be undone after the requesting
28+
test function or fixture has finished. The ``raising``
29+
parameter determines if a ``KeyError`` or ``AttributeError``
30+
will be raised if the target of the set/deletion operation does not exist.
31+
32+
Consider the following scenarios:
33+
34+
1. Modifying the behavior of a function or the property of a class for a test e.g.
35+
there is an API call or database connection you will not make for a test but you know
36+
what the expected output should be. Use :py:meth:`monkeypatch.setattr` to patch the
37+
function or property with your desired testing behavior. This can include your own functions.
38+
Use :py:meth:`monkeypatch.delattr` to remove the function or property for the test.
39+
40+
2. Modifying the values of dictionaries e.g. you have a global configuration that
41+
you want to modify for certain test cases. Use :py:meth:`monkeypatch.setitem` to patch the
42+
dictionary for the test. :py:meth:`monkeypatch.delitem` can be used to remove items.
43+
44+
3. Modifying environment variables for a test e.g. to test program behavior if an
45+
environment variable is missing, or to set multiple values to a known variable.
46+
:py:meth:`monkeypatch.setenv` and :py:meth:`monkeypatch.delenv` can be used for
47+
these patches.
48+
49+
4. Use :py:meth:`monkeypatch.syspath_prepend` to modify the system ``$PATH`` safely, and
50+
:py:meth:`monkeypatch.chdir` to change the context of the current working directory
51+
during a test.
52+
1253
See the `monkeypatch blog post`_ for some introduction material
1354
and a discussion of its motivation.
1455

1556
.. _`monkeypatch blog post`: http://tetamap.wordpress.com/2009/03/03/monkeypatching-in-unit-tests-done-right/
1657

17-
1858
Simple example: monkeypatching functions
1959
----------------------------------------
2060

21-
If you want to pretend that ``os.expanduser`` returns a certain
22-
directory, you can use the :py:meth:`monkeypatch.setattr` method to
23-
patch this function before calling into a function which uses it::
61+
Consider a scenario where you are working with user directories. In the context of
62+
testing, you do not want your test to depend on the running user. ``monkeypatch``
63+
can be used to patch functions dependent on the user to always return a
64+
specific value.
65+
66+
In this example, :py:meth:`monkeypatch.setattr` is used to patch ``Path.home``
67+
so that the known testing path ``Path("/abc")`` is always used when the test is run.
68+
This removes any dependency on the running user for testing purposes.
69+
:py:meth:`monkeypatch.setattr` must be called before the function which will use
70+
the patched function is called.
71+
After the test function finishes the ``Path.home`` modification will be undone.
72+
73+
.. code-block:: python
74+
75+
# contents of test_module.py with source code and the test
76+
from pathlib import Path
77+
78+
79+
def getssh():
80+
"""Simple function to return expanded homedir ssh path."""
81+
return Path.home() / ".ssh"
82+
83+
84+
def test_getssh(monkeypatch):
85+
# mocked return function to replace Path.home
86+
# always return '/abc'
87+
def mockreturn():
88+
return Path("/abc")
2489
25-
# content of test_module.py
26-
import os.path
27-
def getssh(): # pseudo application code
28-
return os.path.join(os.path.expanduser("~admin"), '.ssh')
90+
# Application of the monkeypatch to replace Path.home
91+
# with the behavior of mockreturn defined above.
92+
monkeypatch.setattr(Path, "home", mockreturn)
2993
30-
def test_mytest(monkeypatch):
31-
def mockreturn(path):
32-
return '/abc'
33-
monkeypatch.setattr(os.path, 'expanduser', mockreturn)
94+
# Calling getssh() will use mockreturn in place of Path.home
95+
# for this test with the monkeypatch.
3496
x = getssh()
35-
assert x == '/abc/.ssh'
97+
assert x == Path("/abc/.ssh")
98+
99+
Monkeypatching returned objects: building mock classes
100+
------------------------------------------------------
101+
102+
:py:meth:`monkeypatch.setattr` can be used in conjunction with classes to mock returned
103+
objects from functions instead of values.
104+
Imagine a simple function to take an API url and return the json response.
105+
106+
.. code-block:: python
107+
108+
# contents of app.py, a simple API retrieval example
109+
import requests
110+
111+
112+
def get_json(url):
113+
"""Takes a URL, and returns the JSON."""
114+
r = requests.get(url)
115+
return r.json()
116+
117+
We need to mock ``r``, the returned response object for testing purposes.
118+
The mock of ``r`` needs a ``.json()`` method which returns a dictionary.
119+
This can be done in our test file by defining a class to represent ``r``.
120+
121+
.. code-block:: python
122+
123+
# contents of test_app.py, a simple test for our API retrieval
124+
# import requests for the purposes of monkeypatching
125+
import requests
126+
127+
# our app.py that includes the get_json() function
128+
# this is the previous code block example
129+
import app
130+
131+
# custom class to be the mock return value
132+
# will override the requests.Response returned from requests.get
133+
class MockResponse:
134+
135+
# mock json() method always returns a specific testing dictionary
136+
@staticmethod
137+
def json():
138+
return {"mock_key": "mock_response"}
139+
140+
141+
def test_get_json(monkeypatch):
142+
143+
# Any arguments may be passed and mock_get() will always return our
144+
# mocked object, which only has the .json() method.
145+
def mock_get(*args, **kwargs):
146+
return MockResponse()
147+
148+
# apply the monkeypatch for requests.get to mock_get
149+
monkeypatch.setattr(requests, "get", mock_get)
150+
151+
# app.get_json, which contains requests.get, uses the monkeypatch
152+
result = app.get_json("https://fakeurl")
153+
assert result["mock_key"] == "mock_response"
154+
155+
156+
``monkeypatch`` applies the mock for ``requests.get`` with our ``mock_get`` function.
157+
The ``mock_get`` function returns an instance of the ``MockResponse`` class, which
158+
has a ``json()`` method defined to return a known testing dictionary and does not
159+
require any outside API connection.
160+
161+
You can build the ``MockResponse`` class with the appropriate degree of complexity for
162+
the scenario you are testing. For instance, it could include an ``ok`` property that
163+
always returns ``True``, or return different values from the ``json()`` mocked method
164+
based on input strings.
165+
166+
This mock can be shared across tests using a ``fixture``:
167+
168+
.. code-block:: python
169+
170+
# contents of test_app.py, a simple test for our API retrieval
171+
import pytest
172+
import requests
173+
174+
# app.py that includes the get_json() function
175+
import app
176+
177+
# custom class to be the mock return value of requests.get()
178+
class MockResponse:
179+
@staticmethod
180+
def json():
181+
return {"mock_key": "mock_response"}
182+
183+
184+
# monkeypatched requests.get moved to a fixture
185+
@pytest.fixture
186+
def mock_response(monkeypatch):
187+
"""Requests.get() mocked to return {'mock_key':'mock_response'}."""
188+
189+
def mock_get(*args, **kwargs):
190+
return MockResponse()
191+
192+
monkeypatch.setattr(requests, "get", mock_get)
193+
194+
195+
# notice our test uses the custom fixture instead of monkeypatch directly
196+
def test_get_json(mock_response):
197+
result = app.get_json("https://fakeurl")
198+
assert result["mock_key"] == "mock_response"
199+
200+
201+
Furthermore, if the mock was designed to be applied to all tests, the ``fixture`` could
202+
be moved to a ``conftest.py`` file and use the with ``autouse=True`` option.
36203

37-
Here our test function monkeypatches ``os.path.expanduser`` and
38-
then calls into a function that calls it. After the test function
39-
finishes the ``os.path.expanduser`` modification will be undone.
40204

41205
Global patch example: preventing "requests" from remote operations
42206
------------------------------------------------------------------
43207

44208
If you want to prevent the "requests" library from performing http
45-
requests in all your tests, you can do::
209+
requests in all your tests, you can do:
46210

47-
# content of conftest.py
211+
.. code-block:: python
212+
213+
# contents of conftest.py
48214
import pytest
215+
216+
49217
@pytest.fixture(autouse=True)
50218
def no_requests(monkeypatch):
219+
"""Remove requests.sessions.Session.request for all tests."""
51220
monkeypatch.delattr("requests.sessions.Session.request")
52221
53222
This autouse fixture will be executed for each test function and it
@@ -85,7 +254,7 @@ Monkeypatching environment variables
85254
------------------------------------
86255

87256
If you are working with environment variables you often need to safely change the values
88-
or delete them from the system for testing purposes. ``Monkeypatch`` provides a mechanism
257+
or delete them from the system for testing purposes. ``monkeypatch`` provides a mechanism
89258
to do this using the ``setenv`` and ``delenv`` method. Our example code to test:
90259

91260
.. code-block:: python
@@ -131,6 +300,7 @@ This behavior can be moved into ``fixture`` structures and shared across tests:
131300

132301
.. code-block:: python
133302
303+
# contents of our test file e.g. test_code.py
134304
import pytest
135305
136306
@@ -144,7 +314,7 @@ This behavior can be moved into ``fixture`` structures and shared across tests:
144314
monkeypatch.delenv("USER", raising=False)
145315
146316
147-
# Notice the tests reference the fixtures for mocks
317+
# notice the tests reference the fixtures for mocks
148318
def test_upper_to_lower(mock_env_user):
149319
assert get_os_user_lower() == "testinguser"
150320
@@ -154,6 +324,112 @@ This behavior can be moved into ``fixture`` structures and shared across tests:
154324
_ = get_os_user_lower()
155325
156326
327+
Monkeypatching dictionaries
328+
---------------------------
329+
330+
:py:meth:`monkeypatch.setitem` can be used to safely set the values of dictionaries
331+
to specific values during tests. Take this simplified connection string example:
332+
333+
.. code-block:: python
334+
335+
# contents of app.py to generate a simple connection string
336+
DEFAULT_CONFIG = {"user": "user1", "database": "db1"}
337+
338+
339+
def create_connection_string(config=None):
340+
"""Creates a connection string from input or defaults."""
341+
config = config or DEFAULT_CONFIG
342+
return f"User Id={config['user']}; Location={config['database']};"
343+
344+
For testing purposes we can patch the ``DEFAULT_CONFIG`` dictionary to specific values.
345+
346+
.. code-block:: python
347+
348+
# contents of test_app.py
349+
# app.py with the connection string function (prior code block)
350+
import app
351+
352+
353+
def test_connection(monkeypatch):
354+
355+
# Patch the values of DEFAULT_CONFIG to specific
356+
# testing values only for this test.
357+
monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
358+
monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")
359+
360+
# expected result based on the mocks
361+
expected = "User Id=test_user; Location=test_db;"
362+
363+
# the test uses the monkeypatched dictionary settings
364+
result = app.create_connection_string()
365+
assert result == expected
366+
367+
You can use the :py:meth:`monkeypatch.delitem` to remove values.
368+
369+
.. code-block:: python
370+
371+
# contents of test_app.py
372+
import pytest
373+
374+
# app.py with the connection string function
375+
import app
376+
377+
378+
def test_missing_user(monkeypatch):
379+
380+
# patch the DEFAULT_CONFIG t be missing the 'user' key
381+
monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)
382+
383+
# Key error expected because a config is not passed, and the
384+
# default is now missing the 'user' entry.
385+
with pytest.raises(KeyError):
386+
_ = app.create_connection_string()
387+
388+
389+
The modularity of fixtures gives you the flexibility to define
390+
separate fixtures for each potential mock and reference them in the needed tests.
391+
392+
.. code-block:: python
393+
394+
# contents of test_app.py
395+
import pytest
396+
397+
# app.py with the connection string function
398+
import app
399+
400+
# all of the mocks are moved into separated fixtures
401+
@pytest.fixture
402+
def mock_test_user(monkeypatch):
403+
"""Set the DEFAULT_CONFIG user to test_user."""
404+
monkeypatch.setitem(app.DEFAULT_CONFIG, "user", "test_user")
405+
406+
407+
@pytest.fixture
408+
def mock_test_database(monkeypatch):
409+
"""Set the DEFAULT_CONFIG database to test_db."""
410+
monkeypatch.setitem(app.DEFAULT_CONFIG, "database", "test_db")
411+
412+
413+
@pytest.fixture
414+
def mock_missing_default_user(monkeypatch):
415+
"""Remove the user key from DEFAULT_CONFIG"""
416+
monkeypatch.delitem(app.DEFAULT_CONFIG, "user", raising=False)
417+
418+
419+
# tests reference only the fixture mocks that are needed
420+
def test_connection(mock_test_user, mock_test_database):
421+
422+
expected = "User Id=test_user; Location=test_db;"
423+
424+
result = app.create_connection_string()
425+
assert result == expected
426+
427+
428+
def test_missing_user(mock_missing_default_user):
429+
430+
with pytest.raises(KeyError):
431+
_ = app.create_connection_string()
432+
157433
158434
.. currentmodule:: _pytest.monkeypatch
159435

0 commit comments

Comments
 (0)