Chez Deepki, cela fait des années que l’on utilise pytest pour nos tests. Dans l’ensemble, on en est satisfaits, l’API de pytest est simple et nous permet de faire ce qu’on veut sans grande difficulté. Or, dans la panoplie des outils que propose pytest, on y trouve les inévitables fixtures. Ces fameuses fixtures offrent notamment la possibilité d’être utilisées en autouse, c’est-à-dire de les activer magiquement partout dans le code.

Pendant des années, je me suis demandé s’il fallait utiliser les fixtures en autouse ou si c’était une mauvaise pratique. Et récemment, j’ai enfin trouvé une réponse à ces années de questionnements. Cette réponse permet de mieux maîtriser les effets de bord de ses tests, de laisser moins de choses au hasard et d’avoir des tests qui font exactement ce qu’on attend d’eux. C’est donc le fruit de ces réflexions que je veux vous livrer aujourd’hui dans cet article.

Reprenons du début : déjà, c’est quoi une fixture ?

Supposons qu’on teste une fonction permettant de déterminer l’adresse complète d’une salle de théâtre:

def test_full_address():
    theater = {
        "name": "Théâtre des Blancs-Manteaux",
        "address": "15 Rue des Blancs Manteaux",
        "post_code": 75004,
        "city": "Paris",
        "capacity": 60,
    }
    full_address = full_address(theater)
    assert full_address == "Théâtre des Blancs-Manteaux, 15 Rue des Blancs Manteaux, 75004 Paris"

On peut alors réécrire ce test à l’aide d’une fixture, ce qui donnerait quelque chose comme ça :

@pytest.fixture
def theater():
    return {
        "name": "Théâtre des Blancs-Manteaux",
        "address": "15 Rue des Blancs Manteaux",
        "post_code": 75004,
        "city": "Paris",
        "capacity": 60,
    }
    
def test_full_address(theater):
    full_address = full_address(theater)
    assert full_address == "Théâtre des Blancs-Manteaux, 15 Rue des Blancs Manteaux, 75004 Paris"

Ici, la fixture theater est invoquée en paramètre de notre test test_full_address. Le simple fait de l’évoquer dans les paramètres du test suffit pour exécuter le contenu de la fonction theater avant l’exécution du test. Dans l’essence, c’est seulement ça une fixture, ça permet d’exécuter du code avant de lancer un test. En utilisant yield dans le corps d’une fixture, on peut même exécuter du code avant et après l’exécution du test, ce qui permet de préparer quelque chose en amont du test puis de nettoyer ça après son exécution.

En revanche, ici, j’ai fait exprès de prendre ce cas comme exemple car il illustre parfaitement ce qu’il ne faut pas faire d’après moi. En effet, désormais, je n’utilise plus de fixtures pour initialiser les données de mes tests. Et si vous utilisez encore les fixtures de cette manière, je ne peux que vous conseiller cet excellent article qui vous expliquera en détail comment faire mieux qu’avec des fixtures dans ce genre de cas.

S’il ne faut pas utiliser de fixtures de données, quand utiliser des fixtures ?

Effectivement, si on écarte ce cas, c’est pas forcément super clair de savoir quand il faut utiliser des fixtures. Le cas vraiment typique, comme précisé dans l’article précédemment cité, c’est pour simuler un service extérieur comme un client web ou une base de données.

Dans la collecte de données, il est assez fréquent qu’une partie du code doive faire appel à un service extérieur, c’est le principe-même après tout. Quand c’est le cas et que l’on cherche à tester ce code, on ne va pas tester la chaîne de valeur complète car une partie de la chaîne ne dépend pas de nous. On ne veut donc pas que nos tests fassent réellement des appels à ces services extérieurs.

Pour les simuler, il faut donc qu’on trouve un moyen de s’interposer entre le test et l’appel au service extérieur. Parce qu’on ne veut pas que le test fasse l’appel mais on veut que le vrai code le fasse quand même !

Pour cela, l’idéal, c’est de faire de l’injection de dépendance. Mais comme ce n’est pas toujours très répandu en python ou que parfois la base de code qu’on manipule ne permet pas de faire ça facilement sans faire de grands changements, je vais plutôt utiliser ici ce que pytest propose dans son API, à savoir monkeypatch Cette fixture permet de substituer une fonction du code par une autre. Et c’est exactement ce qu’il nous faut ici.

Monkeypatch : comment ça marche ?

Si je reprends le théâtre de tout à l’heure et que je cherche à tester une fonction collect_show_data qui collecte le nombre de spectateurs qui se sont présentés chaque jour pour un théâtre donné, voilà ce que ça pourrait donner :

def test_one_day(monkeypatch):
    theater = new_theater(name="Théâtre Pixel", capacity=38)
    sample_data = {"date": "2023-11-03", "spectators": 35}
    monkeypatch.setattr(path.to.the.file, "function_to_patch", lambda *args, **kwargs: sample_data)
    
    collect_show_data(theater)

    assert data_collected() == [{
        "name": "Théâtre Pixel",
        "date": "2023-11-03",
        "spectators": 35,
    }]


def test_too_many_spectators(monkeypatch):
    theater = new_theater(name="Théâtre Pixel", capacity=38)
    sample_data = {"date": "2023-11-03", "spectators": 1996}
    monkeypatch.setattr(path.to.the.file, "function_to_patch",, lambda *args, **kwargs: sample_data)
    
    with pytest.raises(TooManySpectatorsError):
        collect_show_data(theater)

    assert data_collected() == []

Dans les deux cas, j’utilise monkeypatch pour simuler une réponse possible du service extérieur qui me donnerait le nombre de spectateurs. Dans le premier cas, je simule des données vraisemblables et j’atteste qu’on a bien sauvegardé les données en base. Dans le deuxième cas, je simule des données aberrantes, puis, j’atteste qu’une erreur a été levée et qu’aucune donnée aberrante n’a été sauvegardée en base.

Ici, monkeypatch nous permet de nous substituer à un service extérieur. Dans ce cas, monkeypatch a bien sa place dans chaque test car on vient simuler des données différentes pour chaque test. Mais ce n’est pas toujours le cas, on peut vouloir simuler un service de façon plus globale et c’est à ce moment-là qu’une fixture peut être particulièrement adaptée.

Utiliser monkeypatch dans une fixture

Dans la collecte de données, il est assez fréquent de vouloir séparer l’étape de collecte de la donnée brute d’une part des différentes étapes de transformation qui en découlent d’autre part. Afin de bien diviser le travail, une stratégie efficace est l’utilisation d’un système de file (queue en anglais).

Une tâche de collecte typique peut donc ressembler à ça :

def collect_data(metadata):
    raw_data = call_service(metadata)
    save_raw_data(metadata, raw_data)
    get_queuing_system().add_task(function_that_process_raw_data, metadata, raw_data)

Or, dans le cas de l’exemple précédent où l’on testait collect_show_data, notre test a peut être mis en queue des tâches de calcul non souhaitées lorsqu’on l’a invoqué. Donc sans s’en rendre compte, on a peut-être créé un effet de bord.

Aïe.

Mais n’ayez crainte ! On peut facilement gérer cet effet de bord avec tout ce que j’ai expliqué précédemment ! En effet, là, on aimerait substituer la fonction get_queuing_system dans notre test pour que ça ne génère pas de nouvelle tâche. On pourrait donc faire ça en utilisant monkeypatch. Et si on mettait monkeypatch dans une fixture ? Comme ça, on pourrait désactiver la mise en queue des tâches seulement en précisant la fixture dans les arguments de notre test. Et on pourrait faire ça pour toute fonction qui mettrait des tâches en queue ! Ce serait pratique !

Voilà un exemple d’implémentation avec l’objet Mock de unittest :

@pytest.fixture
def fake_queuing_system(monkeypatch):
    mock = Mock()
    monkeypatch.setattr(path.to.the.file, "get_queuing_system", lambda: mock)
    return mock

Le test_one_show de tout à l’heure deviendrait alors (seule la première ligne change) :

def test_one_show(monkeypatch, fake_queuing_system):  # we added the fixture here
    theater = new_theater(name="Théâtre Pixel", capacity=38)
    sample_data = {"date": "2023-11-03", "spectators": 35}
    monkeypatch.setattr(path.to.the.file, "function_to_patch",, lambda *args, **kwargs: sample_data)
    
    collect_show_data(theater)

    assert get_spectators_from_database() == [{
        "name": "Théâtre Pixel",
        "date": "2023-11-03",
        "spectators": 58,
    }]

Et l’effet de bord serait géré.

Notez notamment comment, dans notre cas, la fixture est invoquée mais jamais utilisée dans le corps de la fonction. Cela dit, j’ai quand même retourné le mock dans la fixture si jamais on voulait venir tester quelles tâches étaient mises en queue et/ou avec quels arguments.

Pourquoi est-ce qu’on voudrait utiliser des fixtures en autouse ?

L’exemple précédent est assez convaincant pour ce qui est de l’usage des fixtures. Tout test susceptible de mettre en queue des tâches devrait utiliser cette fixture. Mais ne pourrait-on pas aller plus loin en fait ? Pourquoi ne pas utiliser cette fixture tout le temps ?

Après tout, on ne voudra jamais réellement mettre des tâches en queue dans un test ! Donc ça pourrait être alléchant de désactiver le système de queue dans tous les tests. Au pire, un test ne s’en sert pas mais le filet de sécurité serait mis en place. Et comme ça, on empêcherait tout test de faire appel à notre système de queue sans s’en rendre compte ! On empêcherait le “nous” du début de l’article de faire l’erreur qu’on a bel et bien faite. Et c’est exactement pour ces cas qu’on peut utiliser les fixtures en autouse !

À définir, c’est très simple, il suffit de rajouter l’option au décorateur. Voilà par exemple à quoi ressemblerait la fixture précédente si on la passait en autouse :

@pytest.fixture(autouse=True)  # <--L'option s'ajoute ici !
def fake_queuing_system(monkeypatch):
    mock = Mock()
    monkeypatch.setattr(path.to.the.file, "get_queuing_system", lambda: mock)
    return mock

Ensuite, la fixture sera utilisée dans tout le scope où elle est située :

  • si on la définit dans une classe, la fixture sera activée pour tous les tests de cette classe
  • si on la définit dans un fichier de test, la fixture sera activée pour tous les tests de ce fichier de test
  • si on la définit dans un fichier conftest.py, la fixture sera activée pour tous les tests de l’arborescence rattachée à ce fichier

La grande question : autouse ou pas autouse ?

Ça y est, maintenant que les concepts sont exposés, on peut se poser la question qui nous intéresse ici : faut-il utiliser des fixtures en autouse ?

Comme je le disais dans la section précédente, si on utilise autouse, on met en place un filet de sécurité. Et ça, ça veut dire que si demain, une nouvelle personne arrive dans la base de code et ajoute un test qui engendre un effet de bord, il n’aura pas lieu. Or, éviter les effets de bord, c’est plutôt un avantage ! Mais ce qui est gênant ici, c’est que la personne ne s’en rendra même pas compte. Or, il serait peut-être mieux qu’elle le sache ! Parce que si l’effet de bord n’était pas souhaité, elle ne se rendra même pas compte qu’elle en aura engendré un. Le test lui dira juste “Oui, le code que tu as produit est ok !” et en cela, c’est une forme de désinformation qui n’est pas souhaitable !

Si on n’utilise pas l’autouse, certes, on s’expose à ce que des gens créent des effets de bord sans s’en rendre compte. Mais si la population qui écrit les tests maîtrise bien ses effets de bord, dans ce cas, le code est particulièrement propre parce que chaque test qui en crée est plus ou moins “marqué” par la fixture. Donc on sait à la lecture de la signature d’un test quels effets de bord il engendre. Et ça, c’est très agréable. Mais en revanche, c’est assez difficile à maintenir. En effet, le code est en mouvement perpétuel et un test qui engendre un effet de bord un jour ne continuera pas nécessairement d’engendrer ce même effet de bord un autre.

En somme, c’est compliqué.

Si on utilise autouse, ça nous offre plus de sécurité mais on perd en maîtrise de nos effets de bord. Et personnellement, je suis resté à cette étape pendant des années, à me dire que les deux étaient pertinents dans différents cas et que ça dépendait de ce dont on parlait. Mais récemment, j’ai réussi à concilier le meilleur des deux mondes.

Ma conclusion, c’est qu’il faut arrêter d’utiliser des fixtures en autouse et que pour y arriver, il faut utiliser des fixtures en autouse. Je vous ai perdu ? Dans ce cas, je m’explique.

Dans le fond, qu’est-ce qu’on veut idéalement ?

Ce qu’on veut, c’est :

  • empêcher quelqu’un qui ne connaît pas le contexte d’engendrer des effets de bord
  • qu’un test remonte quand il engendre des effets de bord non voulus
  • marquer chaque test qui crée des effets de bord
  • que chaque test marqué comme engendrant des effets de bord en crée bel et bien (on ne veut pas de faux positifs)

Le meilleur des deux mondes

Une fois avoir pris ce recul, je me suis dit qu’on pourrait en fait utiliser une fixture en autouse qui servirait à désactiver le service partout. Et en parallèle, on pourrait faire une autre fixture qui, elle, ne serait pas en autouse et qui permettrait d’activer une simulation du service donné pour les tests qui en ont besoin.

Donc le service serait désactivé par défaut pour tous les tests grâce à la première fixture, mais on pourra le simuler au besoin, test par test, avec la deuxième fixture.

Pour le cas du système de queue que je prenais plus tôt, voilà à quoi ça ressemblerait :

@pytest.fixture(autouse=True)
def disable_queuing_system(monkeypatch):
    def raise_error():
        raise NotImplementedError(
            "Your test is currently using the queuing system (probably as an unwanted side effect). "
            "If it's really what you want to do, please use the fake_queuing_system fixture in your test.",
        )
    monkeypatch.setattr(path.to.the.file, "get_queuing_system", raise_error)


@pytest.fixture()
def fake_queuing_system(monkeypatch):
    def mocked_queuing_system():
        mocked_queuing_system.is_called = True
        return Mock()
    mocked_queuing_system.is_called = False
    monkeypatch.setattr(path.to.the.file, "get_queuing_system", mocked_queuing_system)
    yield
    if not mocked_queuing_system.is_called:
        raise ValueError("fake_queuing_system fixture is present in test but it is not necessary")

Avec ce système, on remplit nos quatre besoins :

  • La NotImplementedError est levée seulement lorsqu’un effet de bord devait avoir lieu.
  • Le message de l’erreur permet d’expliciter l’effet de bord qui aurait eu lieu.
  • Chaque test qui simule le système de queue est marqué par la fixture fake_queuing_system. (Si ce n’était pas le cas, la NotImplementedError serait levée lors de l’exécution du test.)
  • Si un test était marqué à tort, alors la ValueError serait levée.

On est donc bien arrivés à faire ce qu’on voulait ! Or, si ça marche, c’est parce que les deux tests utilisent monkeypatch sur la même fonction mais que la fixture en autouse est exécutée avant celle spécifiée dans le test.

Lorsqu’on a mis en place ce système, la première chose dont on s’est rendus compte, c’est que plusieurs de nos tests utilisaient des fixtures à tort. Nous nous sommes donc empressés de les mettre d’équerre. On pensait, à tort, qu’on maîtrisait bien nos effets de bord mais avec ça, on est certain qu’il n’y a pas de trous dans la raquette et c’est assez agréable.

Une solution pas si compliquée finalement !

Ce que j’aime avec la solution que je vous propose, c’est qu’elle n’ajoute pas de concept nouveau. En fait, j’avais tous les éléments entre les mains pendant des années mais je n’avais juste pas eu l’idée d’utiliser deux fixtures sur la même fonction de cette manière !

Je trouve cette solution élégante et j’aime qu’elle offre à la fois un filet de sécurité, une maîtrise des effets de bord et surtout une auto-documentation avancée ! En effet, les deux erreurs sus-mentionnées permettent à la connaissance de se propager au sein d’une équipe technique sans accrocs et seulement le jour où on en a réellement besoin. En tout cas, j’espère que tout cela vous aura convaincu d’adopter ce système pour vos fixtures à vous !

Pour finir, j’aimerais remercier deux de mes collègues, Jean-Baptiste Potonnier et Julia Cavicchi qui m’ont aidé à maturer mes réflexions et à mettre au point cette solution.