Python for test automation: decorators
Python decorators are functions that modify the behavior of other functions or methods. They are applied to functions using the @
symbol followed by the decorator name on the line directly before the function definition. Decorators allow us to add functionality to existing functions without modifying their code, enhancing code reuse, readability, and maintainability. Decorators are commonly used for tasks such as logging, caching, authentication, retrying, or adding additional functionality to functions dynamically. They provide a powerful mechanism for extending and customizing the behavior of functions and methods in Python, facilitating the implementation of cross-cutting concerns and promoting a modular design approach.
Here is a simple
@my_decorator
def my_function():
...
Decorators are higher order functions that receive another function as an argument, and return another function. Multiple decorators can be applied on a function by decorating decorators:
@my_first_decorator
@my_second_decorator
def my_function():
...
Syntax
The syntax for a basic decorator is as follows:
def my_decorator(func): # func is the function we decorate
def wrapper(*args, **kw): # *args and **kw are they arguments and keyword
return func(*args, **kw) # arguments that are passed by the decorator to
# the decorated function
return wrapper
We can use this decorator on a function:
@my_decorator
def my_function(s: str):
print(s)
my_function("hello")
>>>hello
Currently our decorator does nothing, but wrap the function in another function and simply runs the function. To see more interesting results, we can add a print call to our decorator as well:
def my_decorator(func):
def wrapper(*args, **kw):
print("hello from wrapper")
return func(*args, **kw)
return wrapper
Now when we run my_function
again, we see:
@my_decorator
def my_function(s: str):
print(s)
my_function("hello")
>>>hello from wrapper
>>>hello
We can now see that by using a decorator, we can run some code right before a function is executed, and right after a function is executed. For example we could log how long it takes to run a specific function:
import time
def my_logger(func):
def wrapper(*args, **kw):
start = time.time()
result = func(*args, **kw)
end = time.time()
print("The function took", end - start, "seconds to complete")
return result
return wrapper
@my_logger
def my_function():
time.sleep(2)
print("hello")
my_function()
>>>hello
>>>The function took 2.002782106399536 seconds to complete
Timeout decorator
We can construct a decorator that will try to run a function for a certain amount of time, and return the result if it succeeds, and raise an exception if the function call has not succeeded within the given timeout.
def timeout(func):
def wrapper(*args, *kw):
end_time = time.time() + 30
ex = None
while time.time() < end_time:
try:
return func(*args, **kw)
except Exception as ex:
time.sleep(2)
if ex is not None:
raise ex
return wrapper
This decorator runs a while loop for 30 seconds, and tries to run the decorated function. In case an exception was raised by the decorated function, we simply sleep for 2 seconds and try again. The while
loop exists if the function call does not raise an exception, or when time runs out, at which point the caught exception is raised.
Now the timeout and retry intervals are hard coded into the decorator, but what about if we want to define those values any time we're using the decorator for different functions?
For that we need to pass arguments to the decorator. To do this, we need to utilize two wrapper functions, instead of one. The decorator function timeout
receives two optional arguments; timeout and interval corresponding to how long the timeout is and how ofter we run the decorated function.
def timeout(timeout: float = 30.0, interval: float = 2.0):
def outer_wrapper(func):
def inner_wrapper(*args, **kw):
end_time = time.time() + 30
ex = None
while time.time() < end_time:
try:
return func(*args, **kw)
except Exception as ex:
time.sleep(2)
if ex is not None:
raise ex
return inner_wrapper
return outer_wrapper
To test our decorator, we can use it on a function that only raises an exception to see that our decorator actually retries the function for the amount of time we specify, and raises the exception when it does not succeed:
@timeout(timeout=3.0, interval=1.0)
def test():
raise ValueError("some value error")
test()
>>>... # you will see a traceback of the function call here
>>>ValueError: some value error
As an exercise, you can implement your own retry
decorator, which instead of a timeout, has a set number of times the decorator will attempt to run the decorated function.