Testing Python decorators
Inspiration for this post came from my colleague who recently bugged me with question about how to write tests for decorators. The intent is to provide a gentle push for people new to decorators, testing or both to get a grip on the topic.
If you are unfamiliar with decorators — folks at realpython have an excellent guide to start with: Primer on Python Decorators
If you are completely new to writing tests (and have about ~1h of free time on your hands), I strongly recommend reading these wonderful articles first:
You can find all code samples and additional test cases, not mentioned in this post, at my github.
Whenever we compose a test scenario for a regular function there’s (usually) no confusion about how to do it: depending on how we call a function (arguments, context, state of the app, etc.) the product of its activity is considered a success or failure.
Testing decorators is tiny bit different. You need to leave decorated function out of the equation and instead verify whether decorator does what it supposed to.
Coincidentally I have just the example (which I over-simplified) to demonstrate how and why should you cover decorators with test cases.
Imagine we’re working on an application that has two types of users:
- Managers
- Regular users
Sometimes, as a reaction to some trigger, application sends them emails. Users of type manager receive emails from the address management@ecorp.com, all other users receive emails from internal@ecorp.com. The address rule applies to all outgoing emails.
It’s a rule of thumb to keep apples and oranges separately, that’s why we’d like to have one function responsible for establishing connection with email server and sending a message, and another that determines outgoing email address based upon user type.
notify_by_email
function is responsible for establishing connection with email server and sending the message (we’ll get to using_email_address
decorator in a moment).
For simplicity notify_by_email
accepts two parameters: user
(who should receive a message) and from_email
(address from which message should be sent). Let’s skip the content of html_message
and how it gets there, and the process of establishing connection with email server as they are irrelevant to our topic. Pay attention to the fact that notify_by_email
does not return any value. Such functions are usually called from inside of asynchronous process. If something unpredictable happens while execution of such a function it’s added to a log file and does not cause application to crash.
using_email_address
decorator’s job is to decide which email address to send message from (remember: apples and oranges!):
Decorator decides which email address to use based upon type of the user and passes it, together with user
, as from_email
parameter to decorated function.
This allows us to call notify_by_email
anywhere in the code providing only user
argument:
@using_email_address
decorator will take care of which email address to send the message from.
Fast forward couple hundred lines of code and we are happy having our application do bunch of useful tricks, including sending emails to users. We even created a mock to avoid sending actual emails: our test cases instead check whether notify_by_email
made an attempt to establish connection with email server using correct credentials.
Suddenly our application starts sending emails using wrong address to one or both types of users. Writing test cases for notify_by_email
function means we implicitly call using_email_address
with every function call, but don’t actually check what is happening inside the decorator.
Let’s try to approach writing a test case for decorator as if we were writing test for regular function: we need to call using_email_address
with certain input and assert that whatever happens inside of it produces expected output/result. This technique of writing tests is called “black box testing”.
using_email_address
takes one argument: a function. It also has a return value: a function. So to test a decorator we need to pass it a function and expect a function as a returned value. Keeping this in mind let’s start writing test case for using_email_address
. Because we have to pass function as an argument let’s create one and call it to_be_decorated
:
If what you’re seeing doesn’t ring a bell — I’ll give you a little hint: we’ve just done exactly what decorator does with syntactic sugar in form of @
applied to function. This gives us first tip in understanding how to test decorators —wrap it, as you normally would, around a function, only this time use function that you can “glass box”, meaning you have full control over the flow of events inside the function.
Let’s re-write the test in a more pythonic way and run it (even though we don’t have any assert
statements so far):
We receive an error:
TypeError: wrapper() missing 1 required positional argument: ‘user’
This means that thought we’ve re-created the decorator-function relation, where we’ve replaced notify_by_email
with to_be_decorated
function, we’re still missing couple arguments and at least one return statement. We’re almost there! Let’s fix that error:
As you’ve noticed we’ve provided user
fixture to our test case. to_be_decorated
function now expects user
, from_email
and **kwargs
as arguments. And finally we’re calling to_be_decorated
, wrapped in using_email_address
, except this time we can directly access the result of its activity from to_be_decorated
function.
If we run the test case now it should pass. We don’t have any assert
statements, thus it just means that nothing throws exceptions and all parameters are in place.
Let’s update our test case one more time, but this time we make using_email_address
accountable for picking correct email address according to type of the user it receives. The type we’ll be testing against is manager
so after being wrapped with using_email_address
to_be_decorated
function should receive:
user
argument with the samemanager
object it was called with in line 6from_email
argument with valuemanagement@ecorp.com
Voila! Now you know how to approach decorators from testing perspective.
I encourage you to check out sample code for this post. You’ll find few more test examples, that I did not mention here. Sample code is self contained, so you won’t need any additional libraries, except for Python
and PyTest
, to run it and play with your own implementation.