Un test qui doit isoler une fonction de ses dépendances finit souvent par empiler les patch(). Trois dépendances, trois with imbriqués. Cinq dépendances, une pyramide qui pousse le code utile à dix niveaux d’indentation. Le test devient illisible alors que son intention est simple : vérifier un seul comportement à une frontière précise.

contextlib.ExitStack résout exactement ce problème. C’est un gestionnaire de contexte qui en agrège un nombre quelconque d’autres, et les ferme tous proprement à la sortie. Voici comment je m’en sers pour garder un test centré sur sa frontière, avec un cas concret sur l’authentification.

Le problème : la pyramide de with

Prenons une fonction qui traite une requête authentifiée. Elle décode un token, charge l’utilisateur, vérifie une permission, puis exécute le travail métier :

def perform_action(request):
    payload = decode_token(request.token)      # frontière d'auth
    user = get_user(payload["sub"])            # frontière d'auth
    if not has_permission(user, "write"):      # frontière d'auth
        raise PermissionDenied()
    return do_work(user)                        # métier

Pour tester uniquement la frontière d’auth, on ne veut ni vraie clé JWT, ni vraie base de données, ni vrai service de permissions. On veut affirmer une chose : un token valide et un utilisateur autorisé doivent atteindre do_work. Tout le reste est mocké. La logique de validation du token elle-même (signature, expiration) se teste séparément, comme détaillé dans hash, HMAC et sécurité des tokens.

Sans outillage, ça donne ceci :

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

Le corps du test ne fait que quatre lignes, mais c’est difficile à lire : l’intention réelle est enfouie sous quatre niveaux d’indentation.

ExitStack : un seul with pour tous les patches

ExitStack permet d’ouvrir les quatre patches sur un seul niveau d’indentation :

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

Le point clé est enter_context. Cette méthode entre dans le gestionnaire de contexte qu’on lui passe et retourne exactement ce que ce gestionnaire aurait donné via as. Pour un patch(), c’est le mock. On récupère donc decode, get_user, has_perm et do_work comme avant, mais sans imbrication.

À la sortie du bloc with ExitStack(), tous les patches enregistrés sont annulés, dans l’ordre inverse de leur ouverture (LIFO), comme si les with avaient bien été imbriqués. C’est ce qui rend l’équivalence exacte : ExitStack ne change pas la sémantique de nettoyage, il aplatit seulement l’écriture.

Plusieurs patch() dans un seul with, sans contextlib

Avant même ExitStack, with accepte déjà plusieurs gestionnaires de contexte séparés par des virgules, sans rien importer :

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

Depuis Python 3.10, on peut entourer la liste de parenthèses pour passer à la ligne proprement, sans les backslashs :

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

C’est la forme la plus simple quand le nombre de patches est fixe et connu à l’écriture : pas d’import, pas d’objet intermédiaire. ExitStack reprend l’avantage dès que cette liste devient dynamique (boucle, condition) ou qu’on veut la sortir du bloc with pour la partager dans un setUp, ce que la forme à virgules ne permet pas.

Pourquoi ça garde le test centré sur sa frontière

Le bénéfice n’est pas seulement cosmétique. En aplatissant les patches, le test affiche clairement ce qui est mocké et ce qui est testé. Les trois lignes decode, get_user, has_perm rendent la frontière d’auth plus facile à repérer. Le lecteur comprend immédiatement que cette frontière est entièrement simulée et que l’objet du test est ce qui se passe juste après.

Quand un test imbrique six with, l’attention se disperse sur la mécanique. Quand il liste six enter_context alignés, suivis de leurs return_value, la structure parle d’elle-même : voici les dépendances neutralisées, voici l’assertion. Le test documente sa propre frontière.

Patcher dynamiquement, ce que les with ne savent pas faire

L’imbrication de with est figée à l’écriture : le nombre de patches est connu d’avance. ExitStack lève cette contrainte, car on peut entrer des contextes dans une boucle ou sous condition.

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

Impossible d’écrire ça avec des with imbriqués sans connaître la liste à l’avance. C’est utile quand la frontière à isoler dépend d’une configuration, d’un paramètre de test, ou qu’on veut neutraliser tout un module d’effets de bord d’un coup. Quand la permission elle-même devient complexe, mieux vaut d’ailleurs l’extraire dans une couche dédiée et la tester à part, par exemple avec DRF Access Policy.

Nettoyage garanti, même en cas d’exception

ExitStack respecte le protocole des gestionnaires de contexte jusqu’au bout. Si le code testé lève une exception au milieu du bloc, tous les patches déjà ouverts sont quand même annulés. Aucun mock ne fuit vers le test suivant, ce qui est précisément le risque qu’on veut éviter avec patch().

On peut aussi enregistrer du nettoyage arbitraire avec callback(), exécuté à la sortie au même titre que les patches :

with ExitStack() as stack:
    stack.enter_context(patch("app.auth.decode_token"))
    tmp = create_temp_store()
    stack.callback(tmp.destroy)   # appelé à la sortie, quoi qu'il arrive
    ...

callback accepte une fonction et ses arguments. Pratique pour brancher un teardown qui n’est pas lui-même un gestionnaire de contexte.

Sans with : partager la frontière dans setUp

Le bloc with n’est pas obligatoire. On peut conserver l’instance, appeler enter_context() au fil de l’eau, puis fermer la pile manuellement avec close(). C’est ce qui permet de patcher une fois dans le setUp d’une TestCase et de partager la frontière entre toutes les méthodes 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)   # fermeture garantie après chaque 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) enregistre la fermeture auprès du framework de test : la pile est dépilée après chaque méthode, même si le test échoue ou si setUp se casse en cours de route. Chaque test repart avec la même frontière d’auth neutralisée, sans dupliquer les patch() dans chaque méthode. Depuis Python 3.11, TestCase.enterContext() fait directement ce travail sans ExitStack explicite, mais le pattern addCleanup(stack.close) reste valable sur toutes les versions.

Quand préférer une autre approche

ExitStack n’est pas toujours le bon outil. Trois alternatives existent selon le cas.

patch.multiple patche plusieurs attributs du même objet ou module en un appel. Si toutes les cibles partagent le même chemin, c’est plus concis :

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

Les décorateurs @patch empilés conviennent quand le nombre de patches est fixe et qu’on accepte leur règle d’ordre : les mocks arrivent en arguments dans l’ordre inverse des décorateurs, ce qui devient une source d’erreurs au-delà de deux ou trois. ExitStack n’a pas ce piège, puisqu’on nomme chaque mock explicitement.

Enfin, avec pytest, une fixture qui encapsule les patches partagés entre plusieurs tests évite de répéter le with ExitStack(). ExitStack reste la bonne réponse quand les patches sont propres à un seul test, ou quand leur nombre varie selon le scénario.

Récapitulatif

BesoinOutil
Plusieurs patches sur un seul niveauExitStack + enter_context
Nombre de patches dynamique ou conditionnelExitStack dans une boucle
Patcher plusieurs attributs du même modulepatch.multiple
Patches fixes partagés entre testsfixture pytest
Teardown arbitraire dans le blocstack.callback()

ExitStack ne remplace pas patch(), il l’orchestre. Son intérêt dans les tests est de rendre visible la frontière qu’on isole, au lieu de la noyer sous une pyramide de with. Un test qui montre clairement ce qu’il neutralise et ce qu’il vérifie est un test qu’on relit sans effort six mois plus tard.