monkeypatch — contrôler l'environnement, le temps et le réseau
Tester du code dépendant de variables d'environnement, de l'horloge, de l'aléatoire ou du réseau. setenv, delenv, setattr, setitem, chdir, l'annulation automatique après chaque test, la règle « patcher là où le nom est recherché », et l'injection de dépendances pour se passer totalement de patch.
Comment voulez-vous apprendre ?
Le problème que nous résolvons
Voici une fonction qui détermine si une application s'exécute en mode débogage. Elle lit une variable d'environnement :
settings.py :
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"test_settings.py :
from settings import debug_enabled
def test_debug_is_off_by_default():
assert debug_enabled() is FalseLancez pytest -q dans votre terminal et le test réussit. Lancez-le à nouveau sur une machine où quelqu'un a exporté APP_DEBUG=1 — l'ordinateur portable d'un collègue ou un serveur de CI configuré pour le débogage — et le même test, sur le même code, échoue :
$ pytest -q
. [100%]
1 passed in 0.01s
$ APP_DEBUG=1 pytest -q
F [100%]
=================================== FAILURES ===================================
_________________________ test_debug_is_off_by_default _________________________
def test_debug_is_off_by_default():
> assert debug_enabled() is False
E assert True is False
E + where True = debug_enabled()
test_settings.py:5: AssertionError
=========================== short test summary info ============================
FAILED test_settings.py::test_debug_is_off_by_default - assert True is False
1 failed in 0.01sRien n'a changé dans le code. Ce qui a changé, c'est le monde qui l'entoure. Variables d'environnement, heure actuelle, nombres aléatoires, répertoire courant, réseau — un code qui lit l'un de ces éléments renvoie une réponse différente selon l'endroit et le moment où il s'exécute, et un test qui ne les contrôle pas ne teste pas uniquement votre code.
Le test doit pouvoir déclarer : pendant toute la durée de ce test, le monde ressemble à ceci — puis tout remettre en place. C'est exactement ce que fait la fixture intégrée monkeypatch.
À la fin de ce chapitre, vous saurez
- Définir et supprimer des variables d'environnement pour un seul test avec
monkeypatch.setenvetmonkeypatch.delenv - Remplacer une fonction avec
monkeypatch.setattr, en utilisant soit un objet et un nom, soit une chaîne"module.attr" - Modifier une seule clé d'un dictionnaire avec
monkeypatch.setitem, et changer le répertoire de travail avecmonkeypatch.chdir - Démontrer que chaque modification est annulée dès que le test prend fin
- Patcher un nom là où il est recherché, et expliquer pourquoi patcher
random.randintn'affecte pasfrom random import randint - Passer une horloge ou une fonction aléatoire en argument, de façon à ce qu'aucun patch ne soit nécessaire
Prérequis : fixtures intégrées.
Avant d'écrire le test
Avec un tel code, l'essentiel du travail s'effectue avant d'écrire le premier def test_. Quatre questions, dans l'ordre.
1. Que promet la fonction — et que lit-elle que vous ne lui avez pas passé en paramètre ? Lisez la fonction et repérez chaque ligne qui puise quelque chose en dehors de ses arguments : os.environ, datetime.now(), random, un chemin de fichier relatif, un dictionnaire au niveau du module, un appel réseau. Chacun d'eux est une entrée cachée. La promesse de la fonction est : « compte tenu de ces entrées cachées, je renvoie ceci ». Un test doit donc figer chaque entrée cachée, sans quoi il teste simplement ce que la machine se trouve contenir par hasard.
2. Que faut-il mettre en place ? Les éléments habituels : un environnement virtuel avec pytest installé, et le module importable depuis le test (le fichier de test placé à côté, ou le projet installé). Ensuite, pour ce sujet, une décision par entrée cachée :
- une variable d'environnement — utilisez
setenv, oudelenvavecraising=False; - une fonction qui communique avec le réseau, le disque ou une source aléatoire — remplacez-la avec
setattr, dans le module qui l'appelle ; - un dictionnaire au niveau du module — modifiez une clé avec
setitem; - un chemin relatif — utilisez
chdirpour vous placer dans untmp_path; - une horloge ou un choix aléatoire dans le code que vous êtes en train d'écrire — passez-le plutôt en argument.
3. Quels cas tester ? Prenons le chargeur de configuration que vous allez concevoir à la fin de ce chapitre. Il lit des valeurs par défaut, puis un fichier optionnel app.cfg, puis les variables d'environnement APP_*. Voici son plan, avant la moindre ligne de code :
| Cas | Le monde pendant le test | Résultat attendu | |---|---|---| | Rien de défini | dossier vide, aucune variable APP_* | port 8000, débogage False, région "bd" | | Le fichier écrase les valeurs par défaut | app.cfg contient port = 9000 | port 9000 | | L'environnement écrase le fichier | app.cfg indique 9000, APP_PORT=7000 | port 7000 | | Une valeur par défaut change | DEFAULTS["region"] vaut "ae" | région "ae" | | Valeur invalide | APP_PORT=70000 | ValueError mentionnant « out of range » | | Lecteur remplacé | read_file renvoie {"port": "1234"} | port 1234 | | Bruit dans votre shell | APP_PORT=5 exporté avant de lancer pytest | chaque ligne ci-dessus reste vérifiée |
La dernière ligne est celle que l'on oublie souvent. Ce n'est pas un test à part entière — c'est une propriété de toute la suite de tests, et c'est ce qui impose que chaque test réinitialise d'abord les variables, et pas seulement ceux qui les définissent.
4. Ce qu'il ne faut pas tester. Ne testez pas que os.environ stocke des chaînes ou que urllib peut récupérer une page — c'est le travail de Python, et c'est déjà testé. Ne laissez jamais un test unitaire toucher au vrai réseau : c'est lent, cela échoue pour des raisons sans rapport avec votre code, et la météo change. Et n'écrivez jamais d'assertion sur le faux objet lui-même. Ce test réussit mais ne prouve absolument rien :
import weather
def test_pointless(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.fetch_temperature("Dhaka") == 35. [100%]
1 passed in 0.01sIl vérifie que le patch est en place, pas que votre code fonctionne. Le faux objet existe pour qu'un autre code — advice, ci-dessous — puisse être testé en s'appuyant dessus. Si l'assertion ne nomme que l'élément que vous avez remplacé, supprimez le test.
Les sections qui suivent mettent en œuvre ce plan, un outil à la fois.
setenv et delenv — l'environnement, test par test
monkeypatch est une fixture comme tmp_path : demandez-la par son nom dans les paramètres du test. Voici quatre tests pour un settings.py un peu plus étoffé :
import os
def debug_enabled():
return os.environ.get("APP_DEBUG", "0") == "1"
def api_port():
return int(os.environ.get("APP_PORT", "8000"))from settings import api_port, debug_enabled
def test_debug_on(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
assert debug_enabled() is True
def test_debug_off_by_default(monkeypatch):
monkeypatch.delenv("APP_DEBUG", raising=False)
assert debug_enabled() is False
def test_port_from_env(monkeypatch):
monkeypatch.setenv("APP_PORT", "9000")
assert api_port() == 9000
def test_port_default(monkeypatch):
monkeypatch.delenv("APP_PORT", raising=False)
assert api_port() == 8000$ pytest -q
.... [100%]
4 passed in 0.01s
$ APP_DEBUG=1 APP_PORT=1234 pytest -q
.... [100%]
4 passed in 0.01sLa seconde exécution est celle qui compte. Le shell a exporté des valeurs qui auraient cassé deux de ces tests, et pourtant les quatre réussissent toujours, car chaque test explicite l'environnement dont il a besoin.
setenv(name, value) définit une variable. delenv(name) en supprime une — et par défaut, elle lève une KeyError si la variable n'existait pas au départ. Un test pour le cas « non définie » ne se soucie pas de savoir si elle l'était auparavant, mais seulement qu'elle ne soit pas définie maintenant ; c'est pourquoi raising=False est presque toujours ce que vous voulez.
Les valeurs sont des chaînes de caractères. L'environnement ne contient que du texte, tout comme os.environ. Convertir "9000" en 9000 est la tâche de votre code, et cette conversion est souvent précisément ce qui mérite d'être testé.
Tout est annulé — la preuve
Nous affirmons que monkeypatch restaure ce qu'il a modifié dès que le test se termine. En voici la preuve :
import os
def test_first(monkeypatch):
monkeypatch.setenv("APP_DEBUG", "1")
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))pytest -q -s (-s laisse passer les affichages de print) :
inside first : 1
.inside second: None
.
2 passed in 0.01sLe second test n'a pas demandé monkeypatch et ne voit toujours rien — la variable a disparu. Comparez cela avec ce qui se produit lorsque vous écrivez directement dans os.environ :
import os
def test_first():
os.environ["APP_DEBUG"] = "1"
print("inside first :", os.environ.get("APP_DEBUG"))
def test_second():
print("inside second:", os.environ.get("APP_DEBUG"))inside first : 1
.inside second: 1
.
2 passed in 0.01sLa valeur a fuité dans le test suivant, et dans chaque test qui le suit au cours de la même exécution. C'est exactement le genre de bogue qui fait réussir un test lorsqu'il est exécuté seul, mais le fait échouer lorsqu'il tourne avec les autres — ou pire, l'inverse.
Il n'y a aucune magie dans cette annulation. Avant chaque modification, monkeypatch enregistre l'ancienne valeur — ou le fait qu'il n'y en avait aucune — et pendant le nettoyage (teardown), il remet en place chaque valeur enregistrée, dans l'ordre inverse. Le teardown s'exécute que le test ait réussi, échoué ou levé une exception ; ainsi, une assertion qui échoue au milieu du test ne peut pas laisser l'environnement pollué. Un os.environ[...] = ... écrit à la main suivi d'une ligne de nettoyage manuel à la fin du test serait justement ignoré à cause de cette assertion défaillante.
setattr — remplacer une fonction pour un test
Les variables d'environnement représentent le cas le plus simple. Le cas le plus difficile concerne le code qui communique avec le réseau :
weather.py :
import json
import urllib.request
def fetch_temperature(city):
url = f"https://api.example.com/weather?city={city}"
with urllib.request.urlopen(url, timeout=5) as response:
return json.load(response)["temp_c"]
def advice(city):
temp = fetch_temperature(city)
if temp >= 30:
return "Hot: carry water"
if temp <= 10:
return "Cold: take a jacket"
return "Mild"La logique intéressante se trouve dans advice : les seuils, les messages. Testez-la telle quelle, et le test dépend d'un serveur, d'une connexion réseau et de la météo du jour. Sur une machine sans réseau :
$ pytest -q --tb=line test_real.py
F [100%]
=================================== FAILURES ===================================
E socket.gaierror: [Errno -5] No address associated with hostname
During handling of the above exception, another exception occurred:
E urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
/usr/lib/python3.12/urllib/request.py:1347: urllib.error.URLError: <urlopen error [Errno -5] No address associated with hostname>
1 failed in 0.01smonkeypatch.setattr remplace fetch_temperature par quelque chose que vous maîtrisez, pour la durée d'un seul test :
import weather
def test_hot_day(monkeypatch):
monkeypatch.setattr(weather, "fetch_temperature", lambda city: 35)
assert weather.advice("Dhaka") == "Hot: carry water"
def test_cold_day(monkeypatch):
monkeypatch.setattr("weather.fetch_temperature", lambda city: 4)
assert weather.advice("Oslo") == "Cold: take a jacket".. [100%]
2 passed in 0.01sIl existe deux manières de désigner la cible, et elles produisent le même résultat :
setattr(weather, "fetch_temperature", new)— l'objet, puis le nom de l'attribut sous forme de chaîne de caractères.setattr("weather.fetch_temperature", new)— une seule chaîne à points ; pytest importeweatheret sépare la dernière partie.
Utilisez la forme avec l'objet lorsque le test importe déjà le module ; utilisez la forme avec chaîne de caractères lorsqu'il n'a pas besoin de le faire. Dans les deux cas, remarquez ce qui est désormais testé : non pas fetch_temperature, que nous avons remplacée, mais les seuils et les messages de advice, qui constituent le code que nous avons écrit. Aucun réseau, aucune attente, la même réponse à chaque exécution — et une température de 35 ou de 4 sur commande, ce que la vraie météo ne vous fournirait jamais à la demande.
Pourquoi employer setattr plutôt qu'un simple weather.fetch_temperature = lambda city: 35 dans le test ? Pour la même raison qu'avec os.environ : une affectation directe n'est jamais annulée, et chaque test suivant au cours de l'exécution recevrait ce faux objet.
La règle : patcher là où le nom est recherché
Voici maintenant l'erreur que tout le monde commet au moins une fois. Un petit module qui lance un dé :
dice.py :
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6Pour tester le jackpot, forcez randint à renvoyer 6. Elle réside dans random, donc patchez-la là-bas :
import random
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.is_jackpot() is TrueF [100%]
=================================== FAILURES ===================================
_________________________________ test_jackpot _________________________________
monkeypatch = <_pytest.monkeypatch.MonkeyPatch object at 0x7fc1b2fdc9b0>
def test_jackpot(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
> assert dice.is_jackpot() is True
E assert False is True
E + where False = <function is_jackpot at 0x7fc1b2fcd800>()
E + where <function is_jackpot at 0x7fc1b2fcd800> = dice.is_jackpot
test_dice.py:8: AssertionError
=========================== short test summary info ============================
FAILED test_dice.py::test_jackpot - assert False is True
1 failed in 0.01sLe patch a été appliqué, et pourtant dice ne l'a jamais vu. Relancez le test plusieurs fois et il réussira parfois — dès lors que le vrai randint tire un 6 par chance. Un test erroné qui passe occasionnellement au vert est pire qu'un test qui reste toujours dans le rouge.
La raison tient à ce que fait réellement from random import randint. Cela ne crée pas un lien vers random.randint. Cela copie la référence vers un nouveau nom, dice.randint, au moment de l'import. Dès lors, deux noms pointent vers la même fonction, et setattr ne modifie que l'un d'eux :
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
FalseLorsque roll() s'exécute, Python recherche randint dans le module dice — c'est là que le nom est recherché. C'est donc là qu'il faut le remplacer :
import dice
def test_jackpot(monkeypatch):
monkeypatch.setattr(dice, "randint", lambda a, b: 6)
assert dice.is_jackpot() is True
def test_no_jackpot(monkeypatch):
monkeypatch.setattr("dice.randint", lambda a, b: 3)
assert dice.is_jackpot() is False.. [100%]
2 passed in 0.01sCinq exécutions d'affilée, cinq succès. L'autre solution consiste à modifier la manière dont dice.py importe la fonction. Avec import random et un appel à random.randint(1, 6), la recherche a lieu dans le module random à chaque appel, et patcher random.randint fonctionne alors :
import random
def roll():
return random.randint(1, 6)
def is_jackpot():
return roll() == 6$ pytest -q # the original test, patching random.randint
. [100%]
1 passed in 0.01sLa règle en une phrase : repérez la ligne qui appelle la fonction, regardez de quel module elle tire ce nom, et patchez le nom à cet endroit.
setitem — une clé d'un dictionnaire
Les paramètres de configuration sont souvent conservés dans un dictionnaire au niveau du module. Remplacer l'intégralité du dictionnaire serait maladroit ; setitem modifie une seule clé et la restaure après coup :
shop.py :
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
def price_with_tax(amount):
total = round(amount * (1 + SETTINGS["tax_rate"]), 2)
return f"{total} {SETTINGS['currency']}"import shop
def test_tax_free_day(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
assert shop.price_with_tax(100) == "100.0 BDT"
def test_other_currency(monkeypatch):
monkeypatch.setitem(shop.SETTINGS, "currency", "INR")
assert shop.price_with_tax(100) == "115.0 INR"
def test_settings_are_back():
assert shop.SETTINGS == {"currency": "BDT", "tax_rate": 0.15}pytest -v :
test_shop.py::test_tax_free_day PASSED [ 33%]
test_shop.py::test_other_currency PASSED [ 66%]
test_shop.py::test_settings_are_back PASSED [100%]
============================== 3 passed in 0.01s ===============================Le troisième test en apporte une nouvelle fois la preuve : après deux tests ayant chacun modifié une clé, le dictionnaire se retrouve exactement dans son état initial. Son pendant, monkeypatch.delitem(d, key), supprime une clé pour la durée d'un test.
chdir et syspath_prepend
Un code qui ouvre un chemin relatif tel que "notes.txt" dépend du répertoire depuis lequel il est exécuté. Combinez chdir avec la fixture tmp_path du chapitre précédent :
notes.py :
from pathlib import Path
def read_notes():
return Path("notes.txt").read_text().splitlines()from pathlib import Path
from notes import read_notes
def test_reads_from_current_directory(tmp_path, monkeypatch):
(tmp_path / "notes.txt").write_text("buy milk\ncall Rina\n")
monkeypatch.chdir(tmp_path)
assert Path.cwd() == tmp_path
assert read_notes() == ["buy milk", "call Rina"]
def test_import_from_a_new_folder(tmp_path, monkeypatch):
(tmp_path / "helper.py").write_text("ANSWER = 42\n")
monkeypatch.syspath_prepend(tmp_path)
import helper
assert helper.ANSWER == 42.. [100%]
2 passed in 0.01schdir modifie le répertoire de travail pour un seul test et le rétablit ensuite. Le second test illustre l'usage principal de syspath_prepend : il place un dossier au tout début de sys.path afin qu'un module qui y est écrit puisse être importé, puis le retire à la fin — bien pratique pour tester du code qui charge des plugins depuis un dossier.
Mieux encore : passer la dépendance en paramètre
Patcher fonctionne, mais chaque patch est un aveu implicite que le code est allé chercher quelque chose à l'extérieur. De plus, certains éléments ne peuvent pas être patchés facilement. datetime.now en est un exemple :
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'L'alternative relève d'un choix de conception : laisser la fonction recevoir l'élément dont elle dépend, avec le véritable élément en valeur par défaut :
shop_hours.py :
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21Le code de production appelle is_open() et obtient la véritable horloge. Un test passe sa propre horloge :
from datetime import datetime
from shop_hours import is_open
def test_closed_just_before_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 8, 59)) is False
def test_open_at_nine():
assert is_open(now=lambda: datetime(2026, 1, 5, 9, 0)) is True
def test_closed_at_nine_pm():
assert is_open(now=lambda: datetime(2026, 1, 5, 21, 0)) is False... [100%]
3 passed in 0.01sAucune fixture, rien à annuler, aucune question de savoir où le nom est recherché. Le test se lit comme une phrase ordinaire — « à 8 h 59 le magasin est fermé » — et les minutes limites cruciales sont faciles à cibler avec précision.
La même approche fonctionne pour l'aléatoire : def pick_winner(customers, choose=random.choice), et un test passe choose=lambda items: items[0]. C'est ce qu'on appelle l'injection de dépendances, et elle ne nécessite aucun framework : un argument avec valeur par défaut suffit amplement. Utilisez-la lorsque vous écrivez le code. Tournez-vous vers monkeypatch lorsque vous testez un code que vous ne pouvez ou ne devez pas modifier, ou lorsque la dépendance est enfouie plusieurs niveaux d'appels plus bas.
Un exemple complet
Voici le chargeur de configuration prévu dans la section « Avant d'écrire le test » : trois niveaux — les valeurs par défaut intégrées, un fichier optionnel app.cfg dans le répertoire courant, et les variables d'environnement qui écrasent les deux. Chaque ligne du plan devient un test.
app_config.py :
import os
from pathlib import Path
DEFAULTS = {"port": "8000", "debug": "0", "region": "bd"}
def read_file(name="app.cfg"):
path = Path(name)
if not path.exists():
return {}
values = {}
for line in path.read_text().splitlines():
if "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip()
return values
def load_config():
# Lowest priority first: defaults, then the file, then the environment.
raw = dict(DEFAULTS)
raw.update(read_file())
for key in raw:
env_name = "APP_" + key.upper()
if env_name in os.environ:
raw[key] = os.environ[env_name]
port = int(raw["port"])
if not 1 <= port <= 65535:
raise ValueError(f"port out of range: {port}")
return {"port": port, "debug": raw["debug"] == "1", "region": raw["region"]}test_app_config.py :
import pytest
import app_config
@pytest.fixture
def clean_env(tmp_path, monkeypatch):
# An empty folder and no APP_ variables: the test starts from nothing.
monkeypatch.chdir(tmp_path)
for name in ("APP_PORT", "APP_DEBUG", "APP_REGION"):
monkeypatch.delenv(name, raising=False)
return tmp_path
def test_defaults(clean_env):
assert app_config.load_config() == {"port": 8000, "debug": False, "region": "bd"}
def test_file_beats_defaults(clean_env):
(clean_env / "app.cfg").write_text("port = 9000\nregion = in\n")
config = app_config.load_config()
assert config["port"] == 9000
assert config["region"] == "in"
def test_env_beats_file(clean_env, monkeypatch):
(clean_env / "app.cfg").write_text("port = 9000\n")
monkeypatch.setenv("APP_PORT", "7000")
assert app_config.load_config()["port"] == 7000
def test_changed_default(clean_env, monkeypatch):
monkeypatch.setitem(app_config.DEFAULTS, "region", "ae")
assert app_config.load_config()["region"] == "ae"
def test_bad_port(clean_env, monkeypatch):
monkeypatch.setenv("APP_PORT", "70000")
with pytest.raises(ValueError, match="out of range"):
app_config.load_config()
def test_reader_can_be_replaced(clean_env, monkeypatch):
monkeypatch.setattr(app_config, "read_file", lambda: {"port": "1234"})
assert app_config.load_config()["port"] == 1234pytest -v :
collected 6 items
test_app_config.py::test_defaults PASSED [ 16%]
test_app_config.py::test_file_beats_defaults PASSED [ 33%]
test_app_config.py::test_env_beats_file PASSED [ 50%]
test_app_config.py::test_changed_default PASSED [ 66%]
test_app_config.py::test_bad_port PASSED [ 83%]
test_app_config.py::test_reader_can_be_replaced PASSED [100%]
============================== 6 passed in 0.01s ===============================Trois détails méritent d'être soulignés.
Premièrement, la fixture clean_env utilise elle-même monkeypatch. Une fixture et le test qui la demande partagent un seul objet monkeypatch ; le test peut donc ajouter son propre setenv par-dessus, et toutes les modifications des deux sont annulées ensemble à la fin.
Deuxièmement, la fixture supprime les variables au lieu de simplement présumer de leur absence. C'est ce qui fait que la suite renvoie le même résultat avec APP_PORT=5 APP_REGION=xx pytest -q que sans ces variables — essayez ; vous obtiendrez toujours 6 passed. C'est la dernière ligne du plan, satisfaite par conception plutôt que par un test dédié.
Troisièmement, le dernier test patche app_config.read_file — le module dans lequel load_config recherche le nom. Comme les deux fonctions résident dans le même module, c'est le seul endroit qui importe.
En cas de problème
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' Une faute de frappe dans le nom de l'attribut. setattr refuse de créer un attribut qui n'existe pas déjà, ce qui vous protège contre le fait de « patcher » un nom que personne ne lira jamais. La forme avec chaîne de caractères le formule différemment — AttributeError: 'module' object at weather has no attribute 'fetch_temprature' — mais a la même signification. Ne passez raising=False que si vous avez réellement l'intention d'ajouter un nouvel attribut.
Le patch est appliqué mais la vraie fonction s'exécute toujours Aucune erreur n'est levée : le test échoue avec la vraie valeur, ou réussit par chance. Vous avez patché le nom là où il est défini (random.randint) au lieu de le faire là où il est recherché (dice.randint). Regardez la ligne d'import dans le code testé.
KeyError: 'APP_DEBUG' provenant de monkeypatch.delenv La variable n'était pas définie, et delenv lève une exception par défaut. Ajoutez raising=False. La même règle s'applique à delitem et delattr.
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly Vous avez écrit setenv("APP_PORT", 9000). Les variables d'environnement sont du texte ; écrivez "9000".
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' Les types intégrés comme datetime.datetime ne peuvent pas voir leurs méthodes remplacées. Passez plutôt l'horloge en argument, comme dans l'exemple is_open.
ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. Une fixture avec scope="module" (ou "session") a demandé monkeypatch, qui ne vit que pour un seul test. Conservez la portée de fonction pour la fixture, ou utilisez pytest.MonkeyPatch.context() à l'intérieur de la fixture à portée plus large et gérez l'annulation vous-même.
Étape 4 sur 6 — Prédire
Vérifiez votre compréhension
Exécutez avec pytest -q -s. Qu'affichent les deux appels à print (CITY n'est pas définie au préalable) ?
import os
def test_one(monkeypatch):
monkeypatch.setenv("CITY", "Dhaka")
print(os.environ.get("CITY", "none"))
def test_two():
print(os.environ.get("CITY", "none"))- ADhaka, puis une erreur : KeyError: 'CITY'
- Bnone, puis none
- CDhaka, puis none
- DDhaka, puis Dhaka
Ce test échoue la plupart du temps et réussit de temps à autre. Quel est le problème ?
# dice.py
from random import randint
def roll():
return randint(1, 6)
# test_dice.py
import random
import dice
def test_roll_six(monkeypatch):
monkeypatch.setattr(random, "randint", lambda a, b: 6)
assert dice.roll() == 6- AIl faut utiliser `setitem` au lieu de `setattr`
- BLa fonction `lambda` ne devrait pas prendre deux paramètres
- C`monkeypatch` ne peut pas patcher les modules de la bibliothèque standard
- DLe patch est appliqué sur `random`, mais `roll()` recherche le nom dans `dice` — patchez `dice.randint`
Quelle ligne remplace le mieux ..., afin que les tests suivants voient à nouveau 0.15 ?
# shop.py
SETTINGS = {"currency": "BDT", "tax_rate": 0.15}
# test_shop.py
import shop
def test_tax_free(monkeypatch):
... # set tax_rate to 0.0 for this test only- Amonkeypatch.setattr(shop, "SETTINGS", {"tax_rate": 0.0})
- Bmonkeypatch.setitem(shop.SETTINGS, "tax_rate", 0.0)
- Cmonkeypatch.setenv("tax_rate", "0.0")
- Dshop.SETTINGS["tax_rate"] = 0.0
Répondre nécessite un compte
Connectez-vous pour vérifier vos réponses
Les questions sont ci-dessus, et les résoudre de tête est ce qui compte vraiment. Connectez-vous pour voir les réponses, les explications et les indices à trois niveaux.
À vous de jouer
Écrivez greeter.py avec une fonction greeting(name) qui renvoie :
"Good morning, <name>"avant 12 h 00,"Good afternoon, <name>"de 12 h 00 à 18 h 00, et"Good evening, <name>"après cela- en majuscules lorsque la variable d'environnement
GREETER_LOUDvaut"1"
et un dictionnaire au niveau du module TEMPLATES = {"morning": "Good morning", ...} dans lequel la fonction puise ses mots.
Puis écrivez test_greeter.py qui :
- Utilise
setenvetdelenv(..., raising=False)pour tester le mode discret et le mode fort, et réussit que votre shell ait définiGREETER_LOUDou non - Utilise
setitempour remplacer le mot du matin par"Shubho shokal"pour un test, et contient un second test prouvant queTEMPLATESa retrouvé son état normal - Teste les heures limites — 11:59, 12:00, 17:59, 18:00
Pour l'étape 3, écrivez-la deux fois. D'abord avec from datetime import datetime dans greeter.py, et essayez monkeypatch.setattr(datetime, "now", ...) — notez précisément l'erreur que vous obtenez. (Remplacer le nom complet greeter.datetime par une petite classe factice de votre cru fonctionne ; observez simplement la quantité de code que cela nécessite.) Modifiez ensuite greeting pour qu'elle accepte now=datetime.now et testez-la en lui passant une horloge.
Laquelle des deux versions préféreriez-vous conserver ? La seconde est la réponse que donnent la plupart des développeurs expérimentés en tests, et après avoir essayé les deux, vous saurez pourquoi par vous-même, et non simplement en lisant cette page.
Solution
Commencez par le plan, comme dans « Avant d'écrire le test ». Les entrées cachées de greeting sont l'horloge, la variable GREETER_LOUD et le dictionnaire TEMPLATES. Les cas : les quatre minutes limites, en mode discret et fort, un template modifié, et la vérification que le template a bien été rétabli.
greeter.py :
import os
from datetime import datetime
TEMPLATES = {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
}
def greeting(name, now=datetime.now):
hour = now().hour
if hour < 12:
part = "morning"
elif hour < 18:
part = "afternoon"
else:
part = "evening"
text = f"{TEMPLATES[part]}, {name}"
if os.environ.get("GREETER_LOUD") == "1":
text = text.upper()
return texttest_greeter.py :
from datetime import datetime
import pytest
import greeter
def at(hour, minute=0):
# A fake clock: a function that always returns the same moment.
return lambda: datetime(2026, 1, 5, hour, minute)
@pytest.fixture
def quiet(monkeypatch):
monkeypatch.delenv("GREETER_LOUD", raising=False)
@pytest.mark.parametrize(
("hour", "minute", "expected"),
[
(11, 59, "Good morning, Rina"),
(12, 0, "Good afternoon, Rina"),
(17, 59, "Good afternoon, Rina"),
(18, 0, "Good evening, Rina"),
],
)
def test_time_boundaries(quiet, hour, minute, expected):
assert greeter.greeting("Rina", now=at(hour, minute)) == expected
def test_quiet_by_default(quiet):
assert greeter.greeting("Arif", now=at(9)) == "Good morning, Arif"
def test_loud(monkeypatch):
monkeypatch.setenv("GREETER_LOUD", "1")
assert greeter.greeting("Arif", now=at(9)) == "GOOD MORNING, ARIF"
def test_custom_morning_word(quiet, monkeypatch):
monkeypatch.setitem(greeter.TEMPLATES, "morning", "Shubho shokal")
assert greeter.greeting("Maya", now=at(8)) == "Shubho shokal, Maya"
def test_templates_are_back():
assert greeter.TEMPLATES["morning"] == "Good morning"pytest -v :
collected 8 items
test_greeter.py::test_time_boundaries[11-59-Good morning, Rina] PASSED [ 12%]
test_greeter.py::test_time_boundaries[12-0-Good afternoon, Rina] PASSED [ 25%]
test_greeter.py::test_time_boundaries[17-59-Good afternoon, Rina] PASSED [ 37%]
test_greeter.py::test_time_boundaries[18-0-Good evening, Rina] PASSED [ 50%]
test_greeter.py::test_quiet_by_default PASSED [ 62%]
test_greeter.py::test_loud PASSED [ 75%]
test_greeter.py::test_custom_morning_word PASSED [ 87%]
test_greeter.py::test_templates_are_back PASSED [100%]
============================== 8 passed in 0.01s ===============================Et avec la variable exportée dans le shell, GREETER_LOUD=1 pytest -q :
........ [100%]
8 passed in 0.01sLes choix de conception sous-jacents :
- L'horloge est un argument, pas un patch. La première tentative,
monkeypatch.setattr(datetime, "now", ...), s'arrête avec l'erreurTypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'. Plutôt que de construire une fausse classedatetime,greetingacceptenow=datetime.now. Les véritables appelants écriventgreeting("Rina")sans rien remarquer ; les tests passent un instant précis. at(hour, minute)construit l'horloge. Une minuscule fonction utilitaire permet de garder chaque test sur une seule ligne lisible. Elle renvoie une fonction, cargreetingappellenow()— passer directement un objetdatetimeéchouerait avecTypeError: 'datetime.datetime' object is not callable.- Des limites par paires. 11:59 et 12:00, 17:59 et 18:00 : une minute de part et d'autre de chaque seuil. Si quelqu'un écrivait par erreur
hour <= 12, le cas de 12:00 le détecterait. quietest une fixture, pas une supposition. Chaque test dont le texte attendu mélange minuscules et majuscules la demande, car il dépend de l'absence deGREETER_LOUD. C'est pourquoi la seconde exécution, avec la variable exportée, donne toujours huit succès.test_loudn'en a pas besoin — il définit lui-même la variable.setitem, puis la preuve.test_custom_morning_wordmodifie une clé ;test_templates_are_backne demande aucune fixture et confirme que la clé a retrouvé sa valeur, ce qui rend l'annulation visible.- Ce qui n'est pas testé : que
str.upper()fonctionne, ou quedatetime.now()renvoie l'heure actuelle. Tous deux relèvent de Python.
Étape 6 sur 6
Défi — le quiz du chapitre
Dix questions, de la plus facile à la plus difficile. Les dernières sont difficiles exprès.
Connectez-vous pour faire le quiz