A test that has to isolate a function from its dependencies often ends up stacking patch() calls. Three dependencies, three nested with blocks. Five dependencies, a pyramid that pushes the useful code ten levels deep. The test becomes unreadable even though its intent is simple: verify a single behavior at a precise boundary.
contextlib.ExitStack solves exactly this problem. It is a context manager that aggregates any number of others and closes them all cleanly on exit. Here is how I use it to keep a test focused on its boundary, with a concrete example on authentication.
The problem: the pyramid of with
Take a function that handles an authenticated request. It decodes a token, loads the user, checks a permission, then runs the business logic:
def perform_action(request):
payload = decode_token(request.token) # auth boundary
user = get_user(payload["sub"]) # auth boundary
if not has_permission(user, "write"): # auth boundary
raise PermissionDenied()
return do_work(user) # business logic
To test only the auth boundary, we want neither a real JWT key, nor a real database, nor a real permission service. We want to assert one thing: a valid token and an authorized user must reach do_work. Everything else is mocked. The token validation logic itself (signature, expiry) is tested separately, as covered in hashing, HMAC and token security.
Without any tooling, this looks like:
def test_authorized_request_reaches_handler():
with patch("app.auth.decode_token") as decode:
with patch("app.auth.get_user") as get_user:
with patch("app.auth.has_permission") as has_perm:
with patch("app.auth.do_work") as do_work:
decode.return_value = {"sub": "user-123"}
get_user.return_value = User(id="user-123")
has_perm.return_value = True
perform_action(make_request(token="whatever"))
do_work.assert_called_once()
The body of the test is only four lines, but it is hard to read: the real intent is buried under four levels of indentation.
ExitStack: one with for every patch
ExitStack lets you open all four patches at a single indentation level:
from contextlib import ExitStack
from unittest.mock import patch
def test_authorized_request_reaches_handler():
with ExitStack() as stack:
decode = stack.enter_context(patch("app.auth.decode_token"))
get_user = stack.enter_context(patch("app.auth.get_user"))
has_perm = stack.enter_context(patch("app.auth.has_permission"))
do_work = stack.enter_context(patch("app.auth.do_work"))
decode.return_value = {"sub": "user-123"}
get_user.return_value = User(id="user-123")
has_perm.return_value = True
perform_action(make_request(token="whatever"))
do_work.assert_called_once()
The key is enter_context. This method enters the context manager you pass it and returns exactly what that manager would have yielded through as. For a patch(), that is the mock. So you get back decode, get_user, has_perm and do_work as before, but without nesting.
When the with ExitStack() block exits, every registered patch is undone, in the reverse order it was opened (LIFO), just as if the with blocks had actually been nested. That is what makes the equivalence exact: ExitStack does not change the cleanup semantics, it only flattens how you write them.
Several patch() calls in one with, without contextlib
Even before ExitStack, with already accepts several context managers separated by commas, without importing anything:
def test_authorized_request_reaches_handler():
with patch("app.auth.decode_token") as decode, \
patch("app.auth.get_user") as get_user, \
patch("app.auth.has_permission") as has_perm:
decode.return_value = {"sub": "user-123"}
has_perm.return_value = True
...
Since Python 3.10, you can wrap the list in parentheses to break lines cleanly, without the backslashes:
with (
patch("app.auth.decode_token") as decode,
patch("app.auth.get_user") as get_user,
patch("app.auth.has_permission") as has_perm,
):
...
This is the simplest form when the number of patches is fixed and known at write time: no import, no intermediate object. ExitStack takes the lead as soon as that list becomes dynamic (a loop, a condition) or you want to lift it out of the with block to share it in a setUp, which the comma form cannot do.
Why this keeps the test focused on its boundary
The benefit is not just cosmetic. By flattening the patches, the test clearly shows what is mocked and what is tested. The three lines decode, get_user, has_perm make the auth boundary easier to spot. The reader immediately understands that this boundary is fully simulated and that the point of the test is what happens right after.
When a test nests six with blocks, attention scatters over the mechanics. When it lists six aligned enter_context calls, followed by their return_value assignments, the structure speaks for itself: here are the neutralized dependencies, here is the assertion. The test documents its own boundary.
Dynamic patching, which nested with cannot do
Nesting with blocks is fixed at write time: the number of patches is known in advance. ExitStack lifts that constraint, because you can enter contexts in a loop or conditionally.
def test_all_external_services_are_isolated():
services = [
"app.auth.decode_token",
"app.auth.get_user",
"app.auth.has_permission",
"app.billing.charge",
"app.notify.send_email",
]
with ExitStack() as stack:
mocks = {
name: stack.enter_context(patch(name))
for name in services
}
mocks["app.auth.decode_token"].return_value = {"sub": "user-123"}
mocks["app.auth.has_permission"].return_value = True
perform_action(make_request(token="whatever"))
mocks["app.billing.charge"].assert_not_called()
You cannot write this with nested with blocks without knowing the list ahead of time. It is useful when the boundary to isolate depends on a configuration, on a test parameter, or when you want to neutralize a whole module of side effects at once. When the permission itself grows complex, it is better to extract it into a dedicated layer and test it separately, for instance with DRF Access Policy.
Cleanup guaranteed, even on exception
ExitStack honors the context manager protocol all the way. If the code under test raises an exception in the middle of the block, every already-opened patch is still undone. No mock leaks into the next test, which is precisely the risk you want to avoid with patch().
You can also register arbitrary cleanup with callback(), run on exit just like the patches:
with ExitStack() as stack:
stack.enter_context(patch("app.auth.decode_token"))
tmp = create_temp_store()
stack.callback(tmp.destroy) # called on exit, no matter what
...
callback takes a function and its arguments. Handy for wiring up a teardown that is not itself a context manager.
Without with: sharing the boundary in setUp
The with block is not mandatory. You can keep the instance, call enter_context() as you go, then close the stack manually with close(). This is what lets you patch once in a TestCase setUp and share the boundary across every test method:
from contextlib import ExitStack
from unittest import TestCase
from unittest.mock import patch
class AuthBoundaryTests(TestCase):
def setUp(self):
stack = ExitStack()
self.addCleanup(stack.close) # guaranteed close after each test
self.decode = stack.enter_context(patch("app.auth.decode_token"))
self.get_user = stack.enter_context(patch("app.auth.get_user"))
self.has_perm = stack.enter_context(patch("app.auth.has_permission"))
def test_authorized_request_reaches_handler(self):
self.decode.return_value = {"sub": "user-123"}
self.has_perm.return_value = True
...
self.addCleanup(stack.close) registers the close with the test framework: the stack is unwound after each method, even if the test fails or setUp breaks midway. Every test starts with the same auth boundary neutralized, without duplicating the patch() calls in each method. Since Python 3.11, TestCase.enterContext() does this directly without an explicit ExitStack, but the addCleanup(stack.close) pattern works on every version.
When to prefer another approach
ExitStack is not always the right tool. Three alternatives exist depending on the case.
patch.multiple patches several attributes of the same object or module in one call. If all targets share the same path, it is more concise:
with patch.multiple("app.auth", decode_token=DEFAULT, get_user=DEFAULT) as mocks:
mocks["decode_token"].return_value = {"sub": "user-123"}
Stacked @patch decorators are fine when the number of patches is fixed and you accept their ordering rule: the mocks arrive as arguments in the reverse order of the decorators, which becomes a source of errors beyond two or three. ExitStack does not have this trap, since you name each mock explicitly.
Finally, with pytest, a fixture that wraps the patches shared across several tests avoids repeating the with ExitStack(). ExitStack remains the right answer when the patches are specific to a single test, or when their number varies by scenario.
Recap
| Need | Tool |
|---|---|
| Several patches at a single level | ExitStack + enter_context |
| Dynamic or conditional number of patches | ExitStack in a loop |
| Patch several attributes of the same module | patch.multiple |
| Fixed patches shared across tests | pytest fixture |
| Arbitrary teardown inside the block | stack.callback() |
ExitStack does not replace patch(), it orchestrates it. Its value in tests is to make the boundary you isolate visible, instead of burying it under a pyramid of with. A test that clearly shows what it neutralizes and what it verifies is a test you reread effortlessly six months later.
