monkeypatch — controle over de omgeving, tijd en het netwerk
Code testen die afhankelijk is van omgevingsvariabelen, de klok, toeval of het netwerk. setenv, delenv, setattr, setitem, chdir, het automatisch ongedaan maken na elke test, de regel "patch waar de naam wordt opgezocht" en dependency injection waarbij helemaal geen patching nodig is.
Hoe wil je leren?
Het probleem dat we oplossen
Hier is een functie die bepaalt of een applicatie in debugmodus draait. De functie leest een omgevingsvariabele uit:
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 FalseVoer pytest -q uit in je terminal en de test slaagt. Voer het nogmaals uit op een machine waar iemand APP_DEBUG=1 heeft geëxporteerd — de laptop van een collega, of een CI-server die is ingesteld voor debugging — en dezelfde test, op dezelfde code, faalt:
$ 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.01sEr is niets veranderd aan de code. Wat veranderde, was de wereld eromheen. Omgevingsvariabelen, de huidige tijd, willekeurige getallen, de huidige map, het netwerk — code die een van deze zaken leest, geeft een ander antwoord afhankelijk van waar en wanneer het draait. Een test die hier geen controle over heeft, test niet alleen jouw code.
De test moet kunnen zeggen: voor de duur van deze test ziet de wereld er zó uit — en vervolgens alles weer netjes terugzetten. Dat is precies wat de ingebouwde monkeypatch-fixture doet.
Aan het einde van dit hoofdstuk kun je
- Omgevingsvariabelen voor één test instellen en verwijderen met
monkeypatch.setenvenmonkeypatch.delenv - Een functie vervangen met
monkeypatch.setattr, met een object en een naam of met een"module.attr"-string - Eén sleutel van een dictionary wijzigen met
monkeypatch.setitem, en de werkmap metmonkeypatch.chdir - Aantonen dat elke wijziging ongedaan wordt gemaakt wanneer de test eindigt
- Een naam patchen waar deze wordt opgezocht, en uitleggen waarom het patchen van
random.randinteenfrom random import randintmist - Een klok of toevalsfunctie als argument meegeven, zodat er helemaal geen patching nodig is
Voorkennis: ingebouwde fixtures.
Voordat je de test schrijft
Bij dit soort code vindt het meeste werk plaats vóór de eerste def test_. Vier vragen, op volgorde.
1. Wat belooft de functie — en wat leest de functie uit dat je niet hebt meegegeven? Lees de functie en markeer elke regel die iets ophaalt van buiten de argumenten: os.environ, datetime.now(), random, een relatief bestandspad, een dictionary op moduleniveau, een netwerkaanroep. Elk daarvan is een verborgen invoer. De belofte van de functie is "gegeven deze verborgen invoerwaarden, retourneer ik dit", en een test moet elke verborgen invoer vastleggen, anders test je wat de machine toevallig op dat moment bevat.
2. Wat moet er klaarstaan? De gebruikelijke zaken: een virtuele omgeving met pytest geïnstalleerd, en de module importeerbaar vanuit de test (het testbestand ernaast, of het project geïnstalleerd). Vervolgens, voor dit onderwerp, één beslissing per verborgen invoer:
- een omgevingsvariabele — stel deze in met
setenv, of verwijder deze metdelenvmetraising=False; - een functie die het netwerk, de schijf of een bron van toeval aanspreekt — vervang deze met
setattr, in de module die de functie aanroept; - een dictionary op moduleniveau — wijzig één sleutel met
setitem; - een relatief pad — gebruik
chdirnaar eentmp_path; - een klok of willekeurige keuze in code die je nu schrijft — geef deze in plaats daarvan mee als argument.
3. Welke gevallen? Neem de configuratielader die je aan het einde van dit hoofdstuk bouwt. Deze leest standaardwaarden, daarna een optioneel app.cfg-bestand en vervolgens APP_*-omgevingsvariabelen. Het testplan, vóórdat er code is geschreven:
| Geval | De wereld tijdens de test | Verwacht | |---|---|---| | Niets ingesteld | lege map, geen APP_*-variabelen | port 8000, debug False, region "bd" | | Bestand overschrijft standaardwaarden | app.cfg bevat port = 9000 | port 9000 | | Omgeving overschrijft bestand | app.cfg bevat 9000, APP_PORT=7000 | port 7000 | | Een standaardwaarde wijzigt | DEFAULTS["region"] is "ae" | region "ae" | | Ongeldige waarde | APP_PORT=70000 | ValueError waarin "out of range" voorkomt | | Lezer verwisseld | read_file retourneert {"port": "1234"} | port 1234 | | Ruis in je shell | APP_PORT=5 geëxporteerd vóór het uitvoeren van pytest | elke rij hierboven blijft gelden |
De laatste rij is degene die men vaak vergeet. Het is geen test op zichzelf — het is een eigenschap van de gehele testsuite, en het is de reden waarom elke test eerst de variabelen moet opruimen, en niet alleen de tests die ze zelf instellen.
4. Wat moet je niet testen. Test niet dat os.environ strings opslaat of dat urllib een pagina kan ophalen — dat is de taak van Python, en dat is al getest. Laat een unittest nooit het echte netwerk aanraken: het is traag, het faalt om redenen die niets met jouw code te maken hebben, en het weer verandert. En doe nooit een assert op de fake zelf. Deze test slaagt en bewijst niets:
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.01sHij controleert of de patch aanwezig is, niet of jouw code werkt. De fake bestaat zodat andere code — zoals advice hieronder — daarbovenop kan worden getest. Als de assert alleen noemt wat je zojuist hebt vervangen, verwijder dan de test.
De volgende paragrafen werken dit plan hulpmiddel voor hulpmiddel uit.
setenv en delenv — de omgeving, per test
monkeypatch is een fixture net als tmp_path: vraag erom via de naam in de parameters van de test. Hier zijn vier tests voor een iets grotere settings.py:
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.01sDe tweede run is degene die ertoe doet. De shell exporteerde waarden die twee van deze tests zouden hebben gebroken, en toch slagen alle vier de tests, omdat elke test expliciet aangeeft welke omgeving hij nodig heeft.
setenv(name, value) stelt een variabele in. delenv(name) verwijdert er een — en standaard werpt dit een KeyError op als de variabele er van meet af aan niet was. Voor een test van het geval "niet ingesteld" maakt het niet uit of de variabele ooit bestond, alleen dat deze nu niet is ingesteld, dus raising=False is bijna altijd wat je wilt.
Waarden zijn strings. De omgeving bevat uitsluitend tekst, en dat geldt ook voor os.environ. Het omzetten van "9000" naar 9000 is de taak van jouw code, en die conversie is vaak precies wat de moeite waard is om te testen.
Alles wordt ongedaan gemaakt — het bewijs
De bewering is dat monkeypatch alles wat het heeft gewijzigd herstelt zodra de test is afgelopen. Hier is het bewijs:
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 laat print door):
inside first : 1
.inside second: None
.
2 passed in 0.01sDe tweede test vroeg helemaal niet om monkeypatch en ziet alsnog niets — de variabele is weg. Vergelijk dat eens met wat er gebeurt wanneer je rechtstreeks naar os.environ schrijft:
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.01sDe waarde is doorgelekt naar de volgende test, en naar elke volgende test in dezelfde run. Dit is precies het soort bug waardoor een test slaagt als je hem los uitvoert, maar faalt wanneer je hem samen met de andere uitvoert — of erger nog, andersom.
Er zit geen magie in het ongedaan maken. Vóór elke wijziging legt monkeypatch de oude waarde vast — of het feit dat er geen waarde was — en tijdens de teardown zet het elke geregistreerde waarde terug, in omgekeerde volgorde. Teardown wordt altijd uitgevoerd, of de test nu slaagde, faalde of een exception opwierp, dus een falende assert halverwege kan de omgeving nooit vervuild achterlaten. Een handgeschreven os.environ[...] = ... gevolgd door een handgeschreven opruimregel aan het einde van de test zou door precies zo'n falende assert worden overgeslagen.
setattr — een functie vervangen voor één test
Omgevingsvariabelen zijn het eenvoudige geval. Het lastigere geval is code die met het netwerk communiceert:
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"De interessante logica zit in advice: de drempelwaarden, de berichten. Als je dit test zoals het nu is, hangt de test af van een server, een netwerkverbinding en het weer van vandaag. Op een machine zonder netwerkverbinding:
$ 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 verwisselt fetch_temperature voor iets waarover jij controle hebt, voor de duur van één 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.01sEr zijn twee manieren om het doelwit aan te duiden, en ze doen hetzelfde:
setattr(weather, "fetch_temperature", new)— het object, en daarna de attribuutnaam als een string.setattr("weather.fetch_temperature", new)— één punt-gescheiden string; pytest importeertweatheren splitst het laatste deel af.
Gebruik de objectvorm wanneer de test de module al importeert; gebruik de stringvorm wanneer dat niet per se hoeft. Hoe dan ook, merk op wat er nu werkelijk wordt getest: niet fetch_temperature, want die hebben we vervangen, maar de drempelwaarden en meldingen in advice, wat de code is die we zelf hebben geschreven. Geen netwerk, geen wachttijd, telkens hetzelfde antwoord — en een temperatuur van 35 of 4 op afroep, iets wat het echte weer je nooit op commando zou geven.
Waarom setattr en niet gewoon weather.fetch_temperature = lambda city: 35 in de test? Om dezelfde reden als bij os.environ: de gewone toewijzing wordt nooit ongedaan gemaakt, en elke latere test in de run zou met de fake worden opgescheept.
De regel: patch waar de naam wordt opgezocht
Dan nu de fout die iedereen een keer maakt. Een kleine module die een dobbelsteen gooit:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6Om de jackpot te testen, dwingen we randint om 6 te retourneren. Het bevindt zich in random, dus patchen we het daar:
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.01sDe patch werd toegepast, maar dice heeft er nooit iets van gemerkt. Voer de test nog een paar keer uit en hij slaagt af en toe — namelijk telkens wanneer de echte randint toevallig een 6 gooit. Een test die fout is en af en toe groen kleurt, is gevaarlijker dan een test die altijd rood is.
De oorzaak is wat from random import randint in werkelijkheid doet. Het creëert geen verwijzing naar random.randint. Het kopieert de referentie naar een nieuwe naam, dice.randint, op het moment van importeren. Daarna zijn er twee namen die naar dezelfde functie wijzen, en setattr verplaatst er maar één:
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
FalseWanneer roll() draait, zoekt Python naar randint in de module dice — dat is waar de naam wordt opgezocht. Dát is dus de plek waar hij vervangen moet worden:
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.01sVijf runs achter elkaar, vijf keer geslaagd. De andere oplossing is om de manier waarop dice.py importeert aan te passen. Met import random en een aanroep naar random.randint(1, 6) vindt het opzoeken bij elke aanroep plaats in de module random, en dan werkt het patchen van random.randint wel:
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.01sDe regel in één zin: zoek de regel die de functie aanroept, kijk uit welke module die de naam haalt, en patch de naam dáár.
setitem — één sleutel van een dictionary
Instellingen worden vaak bewaard in een dictionary op moduleniveau. De hele dictionary vervangen zou onhandig zijn; setitem wijzigt één sleutel en herstelt deze naderhand:
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 ===============================De derde test is opnieuw het bewijs: nadat twee tests elk een sleutel hebben gewijzigd, is de dictionary weer precies zoals het begon. Zijn tegenhanger, monkeypatch.delitem(d, key), verwijdert een sleutel voor één test.
chdir en syspath_prepend
Code die een relatief pad zoals "notes.txt" opent, hangt af van de map waaruit het wordt uitgevoerd. Combineer chdir met tmp_path uit het vorige hoofdstuk:
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 verplaatst de werkmap voor één test en zet deze na afloop weer terug. De tweede test laat syspath_prepend zien in zijn typische toepassing van één regel: het plaatst een map vooraan in sys.path zodat een module die daar is geschreven kan worden geïmporteerd, en verwijdert die weer aan het einde — handig bij het testen van code die plugins uit een map laadt.
Nog beter: geef de afhankelijkheid mee
Patchen werkt, maar elke patch is een kleine bekentenis dat de code naar buiten reikte en iets uit de wereld greep. Sommige dingen laten zich bovendien helemaal niet prettig patchen. datetime.now is daar een voorbeeld van:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'Het alternatief is een ontwerpkeuze: laat de functie datgene waarvan het afhankelijk is ontvangen, met de echte variant als standaardwaarde:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21Productiecode roept is_open() aan en krijgt de echte klok. Een test geeft zijn eigen klok mee:
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.01sGeen fixture, niets om ongedaan te maken, geen vraagstuk over waar een naam wordt opgezocht. De test leest als een gewone zin — "om 8:59 is de winkel gesloten" — en de cruciale grensminuten zijn eenvoudig exact te raken.
Dezelfde opzet werkt voor toeval: def pick_winner(customers, choose=random.choice), waarbij een test choose=lambda items: items[0] meegeeft. Dit heet dependency injection, en het vereist geen enkel framework: een standaardargument volstaat. Gebruik dit wanneer je zelf de code schrijft. Grijp naar monkeypatch wanneer je code test die je niet kunt of mag wijzigen, of wanneer de afhankelijkheid meerdere aanroepen diep begraven ligt.
Een compleet voorbeeld
Dit is de configuratielader die is gepland in "Voordat je de test schrijft": drie lagen — ingebouwde standaardwaarden, een optioneel app.cfg-bestand in de huidige map, en omgevingsvariabelen die beide overschrijven. Elke rij uit het plan wordt één 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 ===============================Drie dingen vallen op.
Ten eerste: de fixture clean_env gebruikt zelf monkeypatch. Een fixture en de test die daarom vraagt, delen één monkeypatch-object, waardoor de test daar bovenop zijn eigen setenv kan toevoegen, en elke wijziging van beide aan het einde gezamenlijk ongedaan wordt gemaakt.
Ten tweede: de fixture verwijdert de variabelen in plaats van erop te vertrouwen dat ze afwezig zijn. Dat is wat ervoor zorgt dat de suite met APP_PORT=5 APP_REGION=xx pytest -q hetzelfde resultaat geeft als zonder — probeer het maar; er staat nog steeds 6 passed. Dat is de laatste rij van het plan, opgelost door het ontwerp in plaats van door een aparte test.
Ten derde: de laatste test patcht app_config.read_file — de module waar load_config de naam opzoekt. Omdat beide functies in dezelfde module staan, is dat de enige plek die ertoe doet.
Wanneer het misgaat
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' Een typefout in de attribuutnaam. setattr weigert een attribuut aan te maken dat nog niet bestaat, wat je beschermt tegen het "patchen" van een naam die niemand ooit leest. De stringvorm formuleert het anders — AttributeError: 'module' object at weather has no attribute 'fetch_temprature' — maar betekent hetzelfde. Geef raising=False alleen mee als het echt je bedoeling is om een nieuw attribuut toe te voegen.
De patch is toegepast, maar de echte functie draait nog steeds Helemaal geen error: de test faalt met de echte waarde, of slaagt bij toeval. Je hebt de naam gepatcht waar deze is gedefinieerd (random.randint) in plaats van waar deze wordt opgezocht (dice.randint). Bekijk de import-regel in de geteste code.
KeyError: 'APP_DEBUG' vanuit monkeypatch.delenv De variabele was niet ingesteld, en delenv werpt standaard een exception op. Voeg raising=False toe. Ditzelfde geldt voor delitem en delattr.
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly Je hebt setenv("APP_PORT", 9000) geschreven. Omgevingsvariabelen zijn tekst; schrijf "9000".
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' Bij ingebouwde types zoals datetime.datetime kunnen methoden niet worden vervangen. Geef de klok in plaats daarvan mee als argument, zoals in het voorbeeld van is_open.
ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. Een fixture met scope="module" (of "session") vroeg om monkeypatch, dat slechts voor één test bestaat. Houd de fixture op function-scope, of gebruik pytest.MonkeyPatch.context() binnen de bredere fixture en beheer het ongedaan maken zelf.
Stap 4 van 6 — Voorspellen
Test je begrip
Uitgevoerd met pytest -q -s: wat laten de twee print-aanroepen zien (CITY is vooraf niet ingesteld)?
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, daarna een error: KeyError: 'CITY'
- Bnone, daarna none
- CDhaka, daarna none
- DDhaka, daarna Dhaka
Deze test faalt meestal en slaagt zo af en toe. Wat is er mis?
# 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- AEr is `setitem` nodig in plaats van `setattr`
- BDe `lambda` hoort geen twee parameters te accepteren
- C`monkeypatch` kan geen modules uit de standaardbibliotheek patchen
- DDe patch staat op `random`, maar `roll()` zoekt de naam op in `dice` — patch `dice.randint`
Welke regel vervangt ... het beste, zodat latere tests weer 0.15 zien?
# 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
Voor antwoorden heb je een account nodig
Log in om je antwoorden te controleren
De vragen staan hierboven, en ze in je hoofd uitwerken is waar het om draait. Log in om de antwoorden, de uitleg en de hints in drie niveaus te zien.
Jouw beurt
Schrijf greeter.py met een functie greeting(name) die het volgende retourneert:
"Good morning, <name>"vóór 12:00,"Good afternoon, <name>"van 12:00 tot 18:00, en"Good evening, <name>"daarna- in hoofdletters wanneer de omgevingsvariabele
GREETER_LOUDgelijk is aan"1"
en een dictionary op moduleniveau TEMPLATES = {"morning": "Good morning", ...} waaruit de functie zijn bewoordingen leest.
Schrijf vervolgens test_greeter.py dat:
setenvendelenv(..., raising=False)gebruikt om de luide en stille gevallen te testen, en slaagt ongeacht ofGREETER_LOUDin je shell is ingesteldsetitemgebruikt om de ochtendbegroeting voor één test te wijzigen in"Shubho shokal", en een tweede test die bewijst datTEMPLATESweer normaal is- De tijdsgrenzen test — 11:59, 12:00, 17:59, 18:00
Schrijf stap 3 twee keer. Eerst met from datetime import datetime in greeter.py, en probeer monkeypatch.setattr(datetime, "now", ...) — let goed op welke error je krijgt. (De gehele naam greeter.datetime vervangen door een eigen kleine fake-klasse werkt wel; let op hoeveel code dat kost.) Pas vervolgens greeting aan zodat deze now=datetime.now accepteert en test dit door een klok mee te geven.
Welke van de twee versies zou je liever behouden? De tweede versie is het antwoord dat de meeste ervaren testers geven, en na beide te hebben geprobeerd weet je uit eigen ervaring waarom, en niet alleen van deze pagina.
Oplossing
Begin met het plan, net als in "Voordat je de test schrijft". De verborgen invoerwaarden van greeting zijn de klok, de variabele GREETER_LOUD en de dictionary TEMPLATES. De gevallen: de vier grensminuten, stil en luid, een aangepast sjabloon, en een controle dat het sjabloon weer is teruggezet.
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 ===============================En met de variabele geëxporteerd in de shell, GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sDe beslissingen erachter:
- De klok is een argument, geen patch. De eerste poging,
monkeypatch.setattr(datetime, "now", ...), strandt metTypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'. In plaats van een fakedatetime-klasse te bouwen, accepteertgreetingnow=datetime.now. Echte aanroepers schrijvengreeting("Rina")en merken niets; tests geven een exact tijdstip mee. at(hour, minute)bouwt de klok. Een kleine helperfunctie houdt elke test op één leesbare regel. Deze retourneert een functie, omdatgreetingnow()aanroept — direct eendatetime-object meegeven zou falen metTypeError: 'datetime.datetime' object is not callable.- Grenzen in paren. 11:59 en 12:00, 17:59 en 18:00: één minuut aan weerszijden van elke grens. Als iemand per ongeluk
hour <= 12schrijft, vangt het geval voor 12:00 dat op. quietis een fixture, geen aanname. Elke test waarvan de verwachte tekst gemengde hoofd- en kleine letters bevat vraagt erom, omdat die ervan afhangt datGREETER_LOUDafwezig is. Dat is waarom de tweede run, met de geëxporteerde variabele, alsnog acht geslaagde tests oplevert.test_loudheeft deze fixture niet nodig — die stelt de variabele immers zelf in.setitem, daarna het bewijs.test_custom_morning_wordwijzigt één sleutel;test_templates_are_backvraagt om geen enkele fixture en bevestigt dat de sleutel weer terug is, wat het ongedaan maken zichtbaar maakt.- Niet getest: dat
str.upper()werkt, of datdatetime.now()de huidige tijd retourneert. Beide horen bij Python.
Stap 6 van 6
Uitdaging — de hoofdstukquiz
Tien vragen van makkelijk naar moeilijk. De laatste zijn met opzet lastig.
Log in om de quiz te maken