About a year ago, I wrote a post about patching in pytest. I created a simple patch, but ultimately wasn’t able to get it to work perfectly, as it was clear that the original function was still running when I ran my test. I couldn’t figure out what I was doing wrong, but I had to look into it again at work this week.
I was trying to test a complicated function that writes a CSV file and sends it to a SFTP. I was getting frustrated because, as I was working on my test, it kept generating unwanted CSVs despite my attempt to mock that part of the function. I went back to my old blog post and realized that I never actually learned what was wrong with my example patch. I asked a coworker, who pointed me to documentation that she had written which explained exactly what I was doing wrong. At that point, I knew I had to share this finding with you, the reader.
Importing Functions
First, I wanted to rewrite the example code I had previously worked with. The import path is important here, so I’ll be more explicit with where everything lives:
To start, we clarify that both of these files live under the same directory. In utils
, we define our “complex” function, which takes 10 seconds to run, and the function that calls it. Finally, in tests
, we write a test that runs the parent function but patches complex_function
so that it has a custom return and won’t actually take 10 seconds to run.
If you write this at home and run the test, it should pass in under one second. So why was I having so much trouble last time and at work?
Separation of Concerns
There’s one important difference between what we see here and what I ran a year ago:
import time
def complex_function():
# this function can do whatever
# it currently returns nothing
time.sleep(10)
print('we did nothing')
def function_one():
# we will get a specific response if our function returns 'banana'
if complex_function() == 'banana':
return 'the patch worked'
return 'failure'
result = function_one()
print(f'Here is our result: {result}')
Originally, I had written a file similar to how I would write at work: defining and invoking the functions in the same place. But in doing so, I accidentally invoked the non-patched function when running my test. If you’re following along at home, you can add result = function_one()
to utils.py
and re-run the test to see for yourself. Moving the invocation to its own file solves this problem and is yet another reason for separation of concerns.
At work, my functions were defined in a utils file, but my mistake was that I passed the path to the file that implemented the functions. To illustrate:
# WRONG
@patch('python-practice.business_logic.my_function')
# RIGHT
@patch('python-practice.define-functions-utils-file.my_function')
My thinking was that I wanted to patch the outcome when the function is run, so I should import it from where it is run, but that didn’t work. I learned that my patch syntax must match location of the function definition, rather than its invocation.
More Specific Tests
Since it didn’t take much to get to the bottom of that mystery, I can add a bit more depth by sharing two unique assertions I made. I mentioned that my function at work generated a CSV, so rather than try and read the results, I wanted to confirm that the function was called the right number of times and with the correct arguments.
To start, let’s change function_one
so that it triggers complex_function
ten times. We’ll also update complex_function
so that it takes an argument:
Say we want to confirm that calling function_one
results in ten invocations of complex_function
:
We’ve made a few changes to our test. First of all, we’re no longer passing self
as the arg, but actually passing an instance of our mocked function. mock_complex_function
corresponds to the @patch
right above it. Next, we invoke function_one
in the test and then make an assertion using call_count
. This test should pass if you run it at home!
One last thing we can try is confirming that complex_function
is invoked with the desired arguments. Based on our new code, each time we run it, we should pass 0, 1, 2, 3 etc. To reduce the amount of code we’re writing, I’ll change the range to 3 and write up an assertion that looks like this:
At the very top, we import call
from the mock
library and then pass it in a list to assert_has_calls
. Note that this test is also checking the order of the calls, so if there’s some chance or randomness or asynchronicity, the test might be flaky.
Sources
This is usually where I put the references I used to write the post, but today I don’t have any! It would be fair to say that means maybe I should have added more depth, but I’ll choose to view it as a positive. I was able to use my experience, the help I received at work this week, and my own code to explain a concept I’ve grown more familiar with. That’s a win!