Using monkeypatch

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:

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:

ch4/monkey/cheese.py
 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():

ch4/monkey/test_cheese.py
 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:

ch4/monkey/test_cheese.py
 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:

ch4/monkey/test_cheese.py
 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:

ch4/monkey/test_cheese.py
 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.