Un test que debe aislar una función de sus dependencias acaba a menudo apilando llamadas a patch(). Tres dependencias, tres with anidados. Cinco dependencias, una pirámide que empuja el código útil diez niveles de indentación. El test se vuelve ilegible aunque su intención sea simple: verificar un solo comportamiento en una frontera precisa.

contextlib.ExitStack resuelve exactamente este problema. Es un gestor de contexto que agrega un número cualquiera de otros y los cierra todos de forma limpia al salir. Así es como lo uso para mantener un test centrado en su frontera, con un caso concreto sobre la autenticación.

El problema: la pirámide de with

Tomemos una función que procesa una petición autenticada. Decodifica un token, carga el usuario, verifica un permiso y luego ejecuta la lógica de negocio:

def perform_action(request):
    payload = decode_token(request.token)      # frontera de auth
    user = get_user(payload["sub"])            # frontera de auth
    if not has_permission(user, "write"):      # frontera de auth
        raise PermissionDenied()
    return do_work(user)                        # negocio

Para probar únicamente la frontera de auth, no queremos ni una clave JWT real, ni una base de datos real, ni un servicio de permisos real. Queremos afirmar una cosa: un token válido y un usuario autorizado deben alcanzar do_work. Todo lo demás se mockea. La lógica de validación del token en sí (firma, expiración) se prueba por separado, como se detalla en hash, HMAC y seguridad de tokens.

Sin ninguna herramienta, esto queda así:

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()

El cuerpo del test son solo cuatro líneas, pero es difícil de leer: la intención real queda enterrada bajo cuatro niveles de indentación.

ExitStack: un solo with para todos los patches

ExitStack permite abrir los cuatro patches en un único nivel de indentación:

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()

La clave es enter_context. Este método entra en el gestor de contexto que se le pasa y devuelve exactamente lo que ese gestor habría dado a través de as. Para un patch(), eso es el mock. Así que recuperamos decode, get_user, has_perm y do_work como antes, pero sin anidamiento.

Al salir del bloque with ExitStack(), todos los patches registrados se deshacen, en el orden inverso al de su apertura (LIFO), como si los with hubieran estado realmente anidados. Eso es lo que hace la equivalencia exacta: ExitStack no cambia la semántica de limpieza, solo aplana la escritura.

Varios patch() en un solo with, sin contextlib

Incluso antes de ExitStack, with ya acepta varios gestores de contexto separados por comas, sin importar nada:

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
        ...

Desde Python 3.10, se puede rodear la lista con paréntesis para saltar de línea de forma limpia, sin las barras invertidas:

    with (
        patch("app.auth.decode_token") as decode,
        patch("app.auth.get_user") as get_user,
        patch("app.auth.has_permission") as has_perm,
    ):
        ...

Es la forma más simple cuando el número de patches es fijo y se conoce al escribir: sin import, sin objeto intermedio. ExitStack toma la delantera en cuanto esa lista se vuelve dinámica (un bucle, una condición) o se quiere sacarla del bloque with para compartirla en un setUp, algo que la forma con comas no permite.

Por qué mantiene el test centrado en su frontera

El beneficio no es solo cosmético. Al aplanar los patches, el test muestra con claridad qué se mockea y qué se prueba. Las tres líneas decode, get_user, has_perm hacen que la frontera de auth sea más fácil de localizar. El lector entiende de inmediato que esa frontera está completamente simulada y que el objeto del test es lo que ocurre justo después.

Cuando un test anida seis with, la atención se dispersa en la mecánica. Cuando lista seis enter_context alineados, seguidos de sus return_value, la estructura habla por sí sola: aquí están las dependencias neutralizadas, aquí está la aserción. El test documenta su propia frontera.

Patchear de forma dinámica, lo que los with anidados no saben hacer

El anidamiento de with es fijo en el momento de escribir: el número de patches se conoce de antemano. ExitStack levanta esa restricción, porque se pueden entrar contextos en un bucle o de forma condicional.

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()

Es imposible escribir esto con with anidados sin conocer la lista de antemano. Es útil cuando la frontera a aislar depende de una configuración, de un parámetro de test, o cuando se quiere neutralizar todo un módulo de efectos secundarios de una vez. Cuando el propio permiso se vuelve complejo, conviene extraerlo a una capa dedicada y probarlo aparte, por ejemplo con DRF Access Policy.

Limpieza garantizada, incluso ante una excepción

ExitStack respeta el protocolo de los gestores de contexto hasta el final. Si el código bajo prueba lanza una excepción en medio del bloque, todos los patches ya abiertos se deshacen igualmente. Ningún mock se filtra al siguiente test, que es precisamente el riesgo que se quiere evitar con patch().

También se puede registrar limpieza arbitraria con callback(), ejecutada a la salida igual que los patches:

with ExitStack() as stack:
    stack.enter_context(patch("app.auth.decode_token"))
    tmp = create_temp_store()
    stack.callback(tmp.destroy)   # llamado a la salida, pase lo que pase
    ...

callback acepta una función y sus argumentos. Práctico para enganchar un teardown que no es en sí mismo un gestor de contexto.

Sin with: compartir la frontera en setUp

El bloque with no es obligatorio. Se puede conservar la instancia, llamar a enter_context() sobre la marcha y luego cerrar la pila manualmente con close(). Eso es lo que permite parchear una vez en el setUp de una TestCase y compartir la frontera entre todos los métodos de test:

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)   # cierre garantizado tras cada 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) registra el cierre en el framework de test: la pila se deshace después de cada método, incluso si el test falla o si setUp se rompe a mitad de camino. Cada test arranca con la misma frontera de auth neutralizada, sin duplicar las llamadas a patch() en cada método. Desde Python 3.11, TestCase.enterContext() hace este trabajo directamente sin un ExitStack explícito, pero el patrón addCleanup(stack.close) sigue siendo válido en todas las versiones.

Cuándo preferir otro enfoque

ExitStack no siempre es la herramienta adecuada. Existen tres alternativas según el caso.

patch.multiple parchea varios atributos del mismo objeto o módulo en una sola llamada. Si todos los objetivos comparten la misma ruta, es más conciso:

with patch.multiple("app.auth", decode_token=DEFAULT, get_user=DEFAULT) as mocks:
    mocks["decode_token"].return_value = {"sub": "user-123"}

Los decoradores @patch apilados sirven cuando el número de patches es fijo y se acepta su regla de orden: los mocks llegan como argumentos en el orden inverso al de los decoradores, lo que se convierte en una fuente de errores más allá de dos o tres. ExitStack no tiene esa trampa, ya que se nombra cada mock de forma explícita.

Por último, con pytest, una fixture que encapsula los patches compartidos entre varios tests evita repetir el with ExitStack(). ExitStack sigue siendo la respuesta correcta cuando los patches son propios de un solo test, o cuando su número varía según el escenario.

Resumen

NecesidadHerramienta
Varios patches en un solo nivelExitStack + enter_context
Número de patches dinámico o condicionalExitStack en un bucle
Parchear varios atributos del mismo módulopatch.multiple
Patches fijos compartidos entre testsfixture de pytest
Teardown arbitrario dentro del bloquestack.callback()

ExitStack no reemplaza a patch(), lo orquesta. Su interés en los tests es hacer visible la frontera que se aísla, en lugar de enterrarla bajo una pirámide de with. Un test que muestra con claridad qué neutraliza y qué verifica es un test que se relee sin esfuerzo seis meses después.