A “monkey patch” is a dynamic modification of a class or module during runtime. During testing, “monkey patching” is a convenient way to take over part of the runtime environment of the code under test and replace either input dependencies or output dependencies with objects or functions that are more convenient for testing. The monkeypatch builtin fixture allows you to do this in the context of a single test. And when the test ends, regardless of pass or fail, the original unpatched is restored, undoing everything changed by the patch. It’s all very hand-wavy until we jump into some examples. After looking at the API, we’ll look at how monkeypatch is used in test code.
The monkeypatch fixture provides the following functions:
setattr(target, name, value=<notset>, raising=True): Set an attribute.
delattr(target, name=<notset>, raising=True): Delete an attribute.
setitem(dic, name, value): Set a dictionary entry.
delitem(dic, name, raising=True): Delete a dictionary entry.
setenv(name, value, prepend=None): Set an environmental variable.
delenv(name, raising=True): Delete an environmental variable.
syspath_prepend(path): Prepend path to sys.path, which is Python’s list of import locations.
chdir(path): Change the current working directory.
The raising parameter tells pytest whether or not to raise an exception if the item doesn’t already exist. The prepend parameter to setenv() can be a character. If it is set, the value of the environmental variable will be changed to value + prepend + <old value>.
To see monkeypatch in action, let’s look at code that writes a dot configuration file. The behavior of some programs can be changed with preferences and values set in a dot file in a user’s home directory. Here’s a bit of code that reads and writes a cheese preferences file:
| import os |
| import json |
| |
| |
| def read_cheese_preferences(): |
| full_path = os.path.expanduser('~/.cheese.json') |
| with open(full_path, 'r') as f: |
| prefs = json.load(f) |
| return prefs |
| |
| |
| def write_cheese_preferences(prefs): |
| full_path = os.path.expanduser('~/.cheese.json') |
| with open(full_path, 'w') as f: |
| json.dump(prefs, f, indent=4) |
| |
| |
| def write_default_cheese_preferences(): |
| write_cheese_preferences(_default_prefs) |
| _default_prefs = { |
| 'slicing': ['manchego', 'sharp cheddar'], |
| 'spreadable': ['Saint Andre', 'camembert', |
| 'bucheron', 'goat', 'humbolt fog', 'cambozola'], |
| 'salads': ['crumbled feta'] |
| } |
Let’s take a look at how we could test write_default_cheese_preferences(). It’s a function that takes no parameters and doesn’t return anything. But it does have a side effect that we can test. It writes a file to the current user’s home directory.
One approach is to just let it run normally and check the side effect. Suppose I already have tests for read_cheese_preferences() and I trust them, so I can use them in the testing of write_default_cheese_preferences():
| def test_def_prefs_full(): |
| cheese.write_default_cheese_preferences() |
| expected = cheese._default_prefs |
| actual = cheese.read_cheese_preferences() |
| assert expected == actual |
One problem with this is that anyone who runs this test code will overwrite their own cheese preferences file. That’s not good.
If a user has HOME set, os.path.expanduser() replaces ~ with whatever is in a user’s HOME environmental variable. Let’s create a temporary directory and redirect HOME to point to that new temporary directory:
| def test_def_prefs_change_home(tmpdir, monkeypatch): |
| monkeypatch.setenv('HOME', tmpdir.mkdir('home')) |
| cheese.write_default_cheese_preferences() |
| expected = cheese._default_prefs |
| actual = cheese.read_cheese_preferences() |
| assert expected == actual |
This is a pretty good test, but relying on HOME seems a little operating-system dependent. And a peek into the documentation online for expanduser() has some troubling information, including “On Windows, HOME and USERPROFILE will be used if set, otherwise a combination of….”[10] Dang. That may not be good for someone running the test on Windows. Maybe we should take a different approach.
Instead of patching the HOME environmental variable, let’s patch expanduser:
| def test_def_prefs_change_expanduser(tmpdir, monkeypatch): |
| fake_home_dir = tmpdir.mkdir('home') |
| monkeypatch.setattr(cheese.os.path, 'expanduser', |
| (lambda x: x.replace('~', str(fake_home_dir)))) |
| cheese.write_default_cheese_preferences() |
| expected = cheese._default_prefs |
| actual = cheese.read_cheese_preferences() |
| assert expected == actual |
During the test, anything in the cheese module that calls os.path.expanduser() gets our lambda expression instead. This little function uses the regular expression module function re.sub to replace ~ with our new temporary directory. Now we’ve used setenv() and setattr() to do patching of environmental variables and attributes. Next up, setitem().
Let’s say we’re worried about what happens if the file already exists. We want to be sure it gets overwritten with the defaults when write_default_cheese_preferences() is called:
| def test_def_prefs_change_defaults(tmpdir, monkeypatch): |
| # write the file once |
| fake_home_dir = tmpdir.mkdir('home') |
| monkeypatch.setattr(cheese.os.path, 'expanduser', |
| (lambda x: x.replace('~', str(fake_home_dir)))) |
| cheese.write_default_cheese_preferences() |
| defaults_before = copy.deepcopy(cheese._default_prefs) |
| |
| # change the defaults |
| monkeypatch.setitem(cheese._default_prefs, 'slicing', ['provolone']) |
| monkeypatch.setitem(cheese._default_prefs, 'spreadable', ['brie']) |
| monkeypatch.setitem(cheese._default_prefs, 'salads', ['pepper jack']) |
| defaults_modified = cheese._default_prefs |
| |
| # write it again with modified defaults |
| cheese.write_default_cheese_preferences() |
| |
| # read, and check |
| actual = cheese.read_cheese_preferences() |
| assert defaults_modified == actual |
| assert defaults_modified != defaults_before |
Because _default_prefs is a dictionary, we can use monkeypatch.setitem() to change dictionary items just for the duration of the test.
We’ve used setenv(), setattr(), and setitem(). The del forms are pretty similar. They just delete an environmental variable, attribute, or dictionary item instead of setting something. The last two monkeypatch methods pertain to paths.
syspath_prepend(path) prepends a path to sys.path, which has the effect of putting your new path at the head of the line for module import directories. One use for this would be to replace a system-wide module or package with a stub version. You can then use monkeypatch.syspath_prepend() to prepend the directory of your stub version and the code under test will find the stub version first.
chdir(path) changes the current working directory during the test. This would be useful for testing command-line scripts and other utilities that depend on what the current working directory is. You could set up a temporary directory with whatever contents make sense for your script, and then use monkeypatch.chdir(the_tmpdir).
You can also use the monkeypatch fixture functions in conjunction with unittest.mock to temporarily replace attributes with mock objects. You’ll look at that in Chapter 7, Using pytest with Other Tools.