Search code examples
pythonpython-unittestpatch

Python patching order is unexpected


Given the following code:

import os
from unittest.mock import patch
 
def sys_exit_new1():
    print("sys_exit_new1:", os.environ.get("BANANA"))
def sys_exit_new2():
    print("sys_exit_new2:", os.environ.get("BANANA"))
 
@patch("sys.exit", new_callable=sys_exit_new1)
@patch.dict(os.environ, {"BANANA": "1"})
@patch.dict(os.environ, {"BANANA": "2"})
@patch("sys.exit", new_callable=sys_exit_new2)
@patch.dict(os.environ, {"BANANA": "3"})
def test_mytest(m1, m2):
    ...
 
test_mytest()

The test will produce:

sys_exit_new2: 2
sys_exit_new1: 2

Can someone please explain why? The documentation says:

Note that the decorators are applied from the bottom upwards. This is the standard way that Python applies decorators. The order of the created mocks passed into your test function matches this order.

If this was true, I would expect it to output:

sys_exit_new2: 3
sys_exit_new2: 1

So patch.dict is behaving differently.


Solution

  • unittest.mock.patch does something kind of weird, that most decorators, including unittest.mock.patch.dict, don't do.

    The first time you decorate a function with unittest.mock.patch, it generates a patcher wrapper, and sets a patchings attribute on the wrapper, containing a list of stuff to patch. If you apply unittest.mock.patch again, it just updates the patchings list, rather than generating a new wrapper.

    So, when you decorate your function, these things happen in order:

    1. @patch.dict(os.environ, {"BANANA": "3"}) generates a wrapper that patches os.environ['BANANA'] to "3" and calls the original function.

    2. @patch("sys.exit", new_callable=sys_exit_new2) generates a wrapper with a patchings attribute. This wrapper applies whatever patches the patchings list says to apply, then calls the BANANA-3 wrapper.

    3. @patch.dict(os.environ, {"BANANA": "2"}) generates a wrapper that patches os.environ['BANANA'] to "2" and calls the patch wrapper.

    4. @patch.dict(os.environ, {"BANANA": "1"}) generates a wrapper that patches os.environ['BANANA'] to "1" and calls the BANANA-2 wrapper.

    5. @patch("sys.exit", new_callable=sys_exit_new1) detects that the BANANA-1 wrapper already has a patchings list (inherited from the BANANA-2 wrapper, which inherited it from the original patch wrapper). Instead of generating a new wrapper, it updates the patchings list and returns the BANANA-1 wrapper.

    So at the end, test_mytest is set to the BANANA-1 wrapper, which calls the BANANA-2 wrapper, which calls the patch wrapper, which calls the BANANA-3 wrapper, which calls the original function.

    When you call test_mytest, you're calling the BANANA-1 wrapper. That calls the BANANA-2 wrapper, which calls the patch wrapper. At the point the patch wrapper is called, os.environ['BANANA'] is set to "2". At this point, the patch wrapper applies both patches the patchings list says to apply, so it calls your sys_exit_new1 and sys_exit_new2 functions. Since os.environ['BANANA'] is set to "2" at this point, that's what both your functions print.