Understand Context Managers in Python and Learn to Use Them in Unit Tests | by Lynn Kwong | Jul, 2022
Use context managers to make your code more robust and more Pythonic
We have been using the with
keyword with the open
function to create a context that will close the file after we have finished reading/writing. However, context managers in Python are not limited to managing external resources like file descriptors, database connections, threading locks, etc, they can be used to provide all kinds of contexts which require some setup and teardown actions before and after the main code block is executed. In this post, we will demystify the context manager in Python by explaining all the technical mechanisms with simple code snippets. We will also introduce how to use mocks/patches in Python unit tests where context managers are heavily used.
Compare context manager with try/finally statement
When used to manage external resources, the with
context manager can be seen as the syntax sugar for the try/finally
statement. Both will close the external resource at the end no matter whether the main code is successful or not. The following code snippets using the with
context manager and the try/finally
statement are equivalent:
As we see, with the context manager, the code can be more concise.
Create a custom context manager
Let’s now create a custom context manager in order to understand the technical mechanism behind the scene.
To create a context manager, we can create a class that implements the __enter__()
and __exit__()
magic methods. __enter__()
includes the setup code for the context and will be executed when the context is created. It can return a value that will be assigned to the variable in the with … as <var>
statement. On the other hand, __exit__()
includes the teardown code that will be executed when the context is exited, regardless of whether the main code block raises an exception or not. If an exception is raised in the main code block, the exception type, exception value, and traceback will be passed to exc_type
, exc_value
and exc_tb
, respectively.
Now the custom context manager should work in the same way as the built-in open
function:
Create a custom context manager using a generator function
Above we created a custom context manager using the classical class-based protocol which implements the __enter__()
and __exit__()
method. Similar to iterators, we can create a context manager using a generator function in a more concise way. To do this, we just need to decorate the generator function with the contextmanager
decorator from the contextlib
library:
Now we can use the generator function as a context manager:
As we see, the code before and after the yield
statement corresponds to the __enter__()
and __exit__()
methods, respectively. The yielded value, if any, is bound to the variable in the with … as <var>
statement. Note that if nothing is yielded, the yield
statement should also be present otherwise it would not be a generator function. There will be an example for this case soon.
Mock a function with a context manager
With a context manager, we can change the behavior of a function in the context and reset it when it exits the context. This is actually the mechanism of how patching works in unit tests. Let’s mock the datetime.now()
function with a context manager:
Keynotes:
- We must assign the
datetime.now
function object to a new variable, otherwise, it cannot be assigned a new value to change its behavior in the context manager. Later you will see that we can use the special patch function fromunittest.mock
to mock any function/method very conveniently, with no need to create new intermediate variables. - A generator-based context manager is created here, you can also create a class-based one, which should be very straightforward. Just copy the code before and after the
yield
statement to the__enter__()
and__exit__()
methods, respectively. - In the generator function, we must use the
global
keyword to declare thatnow
is a global variable, otherwise, it cannot be accessed there. It is saved to a new variablesaved_now
so it can be reset later. - The
now
variable is then assigned a new lambda function which always returns the samedatetime
object. In this way, the behavior of thenow()
function is changed in the context. - In this context manager, nothing is yielded but we still need to yield explicitly to make it a valid generator function.
- As we already know, the code after the
yield
statement is the teardown part. We should resetnow()
back to the originaldatetime.now()
.
Let’s use this context manager to check the result of the now()
function inside and outside the context:
We can see that the behavior of the now()
function is mocked inside the context and reset back to normal when it exits the context.
Mock datetime.now() in unit tests
Most functions can be mocked directly. However, we cannot mock datetime.now()
directly, otherwise, we will see the following TypeError
:
We need to mock the datetime
module instead. Be extremely careful of the code below, it’s very easy to make mistakes here:
Keynotes:
- You need to install the
pytest
module to run the unit tests:
- Since the
datetime.now()
function cannot be patched directly, we need to patch thedatetime
module instead. Note that we need to patch thedatetime
module imported in the current test file, not the one in the built-indatetime
library. If you changetest_mock_now.datetime
todatetime.datetime
, the test will fail. This is because, with this change, it isdatetime.datetime
that is mocked, not thedatetime
module imported in the current test file. Therefore, the assertions on lines 10 and 11 would fail. Do try it out by ourself so you can understand it better.
Mock requests.get() in unit tests
Actually, mocking is pretty simple in Python unit tests. It is just that the datetime.now()
function from the datetime
library is a bit special. Let’s patch the requests.get()
function which is more commonly used in unit tests to mock the response from some HTTP requests:
This time the requests.get()
function can be patched directly and the code is much simpler.
Mocking is very important in unit tests which can make your test code work independent of external resources and let you focus on the logic and robustness of the code. Almost everything can be mocked in unit tests. We have just introduced the mocking of functions in this post. More examples will be introduced in a more dedicated post for unit tests soon.
In this post, we have introduced the fundamentals of context managers and different ways to implement them in Python. A context manager can be implemented with either the class-based or generator-based protocol, with the latter being more concise. Context managers can make your code more robust because the resources are guaranteed to be closed. Besides, context managers can be used to change the behavior of some functions in the context which makes them widely used in unit tests because they can let your test code work independent of external resources.
Use context managers to make your code more robust and more Pythonic
We have been using the with
keyword with the open
function to create a context that will close the file after we have finished reading/writing. However, context managers in Python are not limited to managing external resources like file descriptors, database connections, threading locks, etc, they can be used to provide all kinds of contexts which require some setup and teardown actions before and after the main code block is executed. In this post, we will demystify the context manager in Python by explaining all the technical mechanisms with simple code snippets. We will also introduce how to use mocks/patches in Python unit tests where context managers are heavily used.
Compare context manager with try/finally statement
When used to manage external resources, the with
context manager can be seen as the syntax sugar for the try/finally
statement. Both will close the external resource at the end no matter whether the main code is successful or not. The following code snippets using the with
context manager and the try/finally
statement are equivalent:
As we see, with the context manager, the code can be more concise.
Create a custom context manager
Let’s now create a custom context manager in order to understand the technical mechanism behind the scene.
To create a context manager, we can create a class that implements the __enter__()
and __exit__()
magic methods. __enter__()
includes the setup code for the context and will be executed when the context is created. It can return a value that will be assigned to the variable in the with … as <var>
statement. On the other hand, __exit__()
includes the teardown code that will be executed when the context is exited, regardless of whether the main code block raises an exception or not. If an exception is raised in the main code block, the exception type, exception value, and traceback will be passed to exc_type
, exc_value
and exc_tb
, respectively.
Now the custom context manager should work in the same way as the built-in open
function:
Create a custom context manager using a generator function
Above we created a custom context manager using the classical class-based protocol which implements the __enter__()
and __exit__()
method. Similar to iterators, we can create a context manager using a generator function in a more concise way. To do this, we just need to decorate the generator function with the contextmanager
decorator from the contextlib
library:
Now we can use the generator function as a context manager:
As we see, the code before and after the yield
statement corresponds to the __enter__()
and __exit__()
methods, respectively. The yielded value, if any, is bound to the variable in the with … as <var>
statement. Note that if nothing is yielded, the yield
statement should also be present otherwise it would not be a generator function. There will be an example for this case soon.
Mock a function with a context manager
With a context manager, we can change the behavior of a function in the context and reset it when it exits the context. This is actually the mechanism of how patching works in unit tests. Let’s mock the datetime.now()
function with a context manager:
Keynotes:
- We must assign the
datetime.now
function object to a new variable, otherwise, it cannot be assigned a new value to change its behavior in the context manager. Later you will see that we can use the special patch function fromunittest.mock
to mock any function/method very conveniently, with no need to create new intermediate variables. - A generator-based context manager is created here, you can also create a class-based one, which should be very straightforward. Just copy the code before and after the
yield
statement to the__enter__()
and__exit__()
methods, respectively. - In the generator function, we must use the
global
keyword to declare thatnow
is a global variable, otherwise, it cannot be accessed there. It is saved to a new variablesaved_now
so it can be reset later. - The
now
variable is then assigned a new lambda function which always returns the samedatetime
object. In this way, the behavior of thenow()
function is changed in the context. - In this context manager, nothing is yielded but we still need to yield explicitly to make it a valid generator function.
- As we already know, the code after the
yield
statement is the teardown part. We should resetnow()
back to the originaldatetime.now()
.
Let’s use this context manager to check the result of the now()
function inside and outside the context:
We can see that the behavior of the now()
function is mocked inside the context and reset back to normal when it exits the context.
Mock datetime.now() in unit tests
Most functions can be mocked directly. However, we cannot mock datetime.now()
directly, otherwise, we will see the following TypeError
:
We need to mock the datetime
module instead. Be extremely careful of the code below, it’s very easy to make mistakes here:
Keynotes:
- You need to install the
pytest
module to run the unit tests:
- Since the
datetime.now()
function cannot be patched directly, we need to patch thedatetime
module instead. Note that we need to patch thedatetime
module imported in the current test file, not the one in the built-indatetime
library. If you changetest_mock_now.datetime
todatetime.datetime
, the test will fail. This is because, with this change, it isdatetime.datetime
that is mocked, not thedatetime
module imported in the current test file. Therefore, the assertions on lines 10 and 11 would fail. Do try it out by ourself so you can understand it better.
Mock requests.get() in unit tests
Actually, mocking is pretty simple in Python unit tests. It is just that the datetime.now()
function from the datetime
library is a bit special. Let’s patch the requests.get()
function which is more commonly used in unit tests to mock the response from some HTTP requests:
This time the requests.get()
function can be patched directly and the code is much simpler.
Mocking is very important in unit tests which can make your test code work independent of external resources and let you focus on the logic and robustness of the code. Almost everything can be mocked in unit tests. We have just introduced the mocking of functions in this post. More examples will be introduced in a more dedicated post for unit tests soon.
In this post, we have introduced the fundamentals of context managers and different ways to implement them in Python. A context manager can be implemented with either the class-based or generator-based protocol, with the latter being more concise. Context managers can make your code more robust because the resources are guaranteed to be closed. Besides, context managers can be used to change the behavior of some functions in the context which makes them widely used in unit tests because they can let your test code work independent of external resources.