monkeypatch — Umgebung, Zeit und Netzwerk kontrollieren
Code testen, der von Umgebungsvariablen, der Uhrzeit, Zufall oder dem Netzwerk abhängt: setenv, delenv, setattr, setitem, chdir, das automatische Undo nach jedem Test, die Regel „Patche dort, wo der Name nachgeschlagen wird“ und Dependency Injection ganz ohne Patchen.
Wie möchtest du lernen?
Das Problem, das wir lösen
Hier ist eine Funktion, die entscheidet, ob eine Anwendung im Debug-Modus läuft. Sie liest eine Umgebungsvariable aus:
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 FalseFühre pytest -q in deinem Terminal aus und der Test besteht. Führe ihn noch einmal auf einem Rechner aus, auf dem jemand APP_DEBUG=1 exportiert hat — auf dem Laptop eines Kollegen oder auf einem CI-Server, der für das Debugging eingerichtet ist — und derselbe Test schlägt bei demselben Code fehl:
$ 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.01sAm Code hat sich nichts geändert. Was sich geändert hat, ist die Welt drumherum. Umgebungsvariablen, die aktuelle Uhrzeit, Zufallszahlen, das aktuelle Verzeichnis, das Netzwerk — Code, der eines davon liest, liefert ein anderes Ergebnis, je nachdem, wo und wann er ausgeführt wird. Ein Test, der diese Faktoren nicht kontrolliert, testet deinen Code nicht isoliert.
Der Test muss vorgeben können: Für die Dauer dieses Tests sieht die Welt so aus — und danach alles wieder zurücksetzen. Genau das macht die eingebaute monkeypatch-Fixture.
Am Ende dieses Kapitels kannst du
- Mit
monkeypatch.setenvundmonkeypatch.delenvUmgebungsvariablen für einen einzelnen Test setzen und entfernen - Eine Funktion mit
monkeypatch.setattrersetzen, entweder über ein Objekt und einen Namen oder über einen"module.attr"-String - Einen einzelnen Schlüssel eines Dictionaries mit
monkeypatch.setitemund das Arbeitsverzeichnis mitmonkeypatch.chdirändern - Nachweisen, dass jede Änderung am Ende des Tests rückgängig gemacht wird
- Einen Namen dort patchen, wo er nachgeschlagen wird, und erklären, warum das Patchen von
random.randintbeifrom random import randintins Leere läuft - Eine Uhr oder eine Zufallsfunktion als Argument übergeben, sodass überhaupt kein Patchen nötig ist
Voraussetzungen: Eingebaute Fixtures.
Bevor du den Test schreibst
Bei solchem Code passiert die meiste Arbeit vor dem ersten def test_. Vier Fragen der Reihe nach.
1. Was verspricht die Funktion — und was liest sie, das du nicht übergeben hast? Lies die Funktion durch und markiere jede Zeile, die etwas außerhalb ihrer Argumente entgegennimmt: os.environ, datetime.now(), random, einen relativen Dateipfad, ein Dictionary auf Modulebene, einen Netzwerkaufruf. Jedes davon ist eine verdeckte Eingabe. Das Versprechen der Funktion lautet: „Bei diesen verdeckten Eingaben gebe ich das hier zurück“. Ein Test muss jede verdeckte Eingabe festlegen, sonst testet er nur das, was auf dem Rechner gerade zufällig vorliegt.
2. Was muss vorhanden sein? Das Übliche: eine virtuelle Umgebung mit installiertem pytest und das Modul aus dem Test heraus importierbar (die Testdatei direkt daneben oder das Projekt installiert). Für dieses Thema kommt dann eine Entscheidung pro verdeckter Eingabe hinzu:
- eine Umgebungsvariable — setze sie mit
setenvoder entferne sie mitdelenvundraising=False; - eine Funktion, die auf das Netzwerk, die Festplatte oder eine Zufallsquelle zugreift — ersetze sie mit
setattr, in dem Modul, das sie aufruft; - ein Dictionary auf Modulebene — ändere einen einzelnen Schlüssel mit
setitem; - ein relativer Pfad — wechsle mit
chdirin eintmp_path; - eine Uhr oder Zufallsauswahl in Code, den du gerade selbst schreibst — übergib sie stattdessen als Argument.
3. Welche Fälle? Nimm den Konfigurationslader, den du am Ende dieses Kapitels bauen wirst. Er liest Standardwerte, dann eine optionale app.cfg und schließlich APP_*-Umgebungsvariablen. Sein Plan vor jeder Zeile Code:
| Fall | Die Welt während des Tests | Erwartetes Ergebnis | |---|---|---| | Nichts gesetzt | leerer Ordner, keine APP_*-Variablen | Port 8000, Debug False, Region "bd" | | Datei überschreibt Standardwerte | app.cfg enthält port = 9000 | Port 9000 | | Umgebung überschreibt Datei | app.cfg enthält 9000, APP_PORT=7000 | Port 7000 | | Ein Standardwert ändert sich | DEFAULTS["region"] ist "ae" | Region "ae" | | Ungültiger Wert | APP_PORT=70000 | ValueError, der „out of range“ erwähnt | | Lesefunktion ausgetauscht | read_file liefert {"port": "1234"} | Port 1234 | | Störfaktoren in der Shell | APP_PORT=5 vor dem Ausführen von pytest exportiert | jede Zeile oben gilt weiterhin |
Die letzte Zeile ist diejenige, die oft vergessen wird. Sie ist kein eigener Test — sie ist eine Eigenschaft der gesamten Testsuite. Und sie entscheidet darüber, dass jeder Test die Variablen zuerst bereinigen muss, nicht nur diejenigen, die sie selbst setzen.
4. Was man nicht testen sollte. Teste nicht, dass os.environ Strings speichert oder dass urllib eine Webseite abrufen kann — das ist Pythons Aufgabe und bereits getestet. Lass einen Unit-Test niemals das echte Netzwerk berühren: Es ist langsam, schlägt aus Gründen fehl, die nichts mit deinem Code zu tun haben, und das Wetter ändert sich. Und prüfe niemals das Fake-Objekt selbst per Assert. Dieser Test besteht und beweist gar nichts:
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.01sEr prüft lediglich, dass der Patch aktiv ist, nicht aber, ob dein Code funktioniert. Das Fake existiert, damit anderer Code — weiter unten advice — darauf aufbauend getestet werden kann. Wenn die Assertion nur das nennt, was du ersetzt hast, lösche den Test.
Die folgenden Abschnitte setzen diesen Plan Werkzeug für Werkzeug um.
setenv und delenv — die Umgebung pro Test
monkeypatch ist eine Fixture wie tmp_path: Fordere sie einfach namentlich in den Parametern des Tests an. Hier sind vier Tests für ein etwas umfangreicheres 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.01sDer zweite Durchlauf ist der entscheidende. Die Shell hat Werte exportiert, die zwei dieser Tests zum Scheitern gebracht hätten — und trotzdem bestehen alle vier, weil jeder Test die Umgebung explizit vorgibt, die er benötigt.
setenv(name, value) setzt eine Variable. delenv(name) entfernt eine — und löst standardmäßig einen KeyError aus, falls die Variable von vornherein gar nicht existierte. Einem Test für den Fall „nicht gesetzt“ ist es egal, ob sie vorher gesetzt war, solange sie jetzt nicht gesetzt ist; daher ist raising=False fast immer das, was du willst.
Werte sind Strings. Die Umgebung speichert ausschließlich Text, und das gilt auch für os.environ. Das Umwandeln von "9000" in 9000 ist Aufgabe deines Codes — und genau diese Typumwandlung lohnt sich oft zu testen.
Alles wird rückgängig gemacht — der Beweis
Die Behauptung lautet, dass monkeypatch alles wiederherstellt, was es geändert hat, sobald der Test beendet ist. Hier ist der Beweis:
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 lässt print-Ausgaben durch):
inside first : 1
.inside second: None
.
2 passed in 0.01sDer zweite Test hat monkeypatch gar nicht angefordert und sieht trotzdem nichts — die Variable ist verschwunden. Vergleiche das mit dem, was passiert, wenn du direkt in os.environ schreibst:
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.01sDer Wert ist in den nächsten Test durchgesickert — und in jeden weiteren Test desselben Testlaufs. Das ist genau die Art von Fehler, die dazu führt, dass ein Test isoliert ausgeführt besteht, aber fehlschlägt, wenn er zusammen mit anderen läuft — oder schlimmer noch: umgekehrt.
Hinter dem Rückgängigmachen steckt keine Zauberei. Vor jeder Änderung zeichnet monkeypatch den alten Wert auf — oder die Tatsache, dass es keinen gab — und setzt beim Teardown jeden aufgezeichneten Wert in umgekehrter Reihenfolge wieder zurück. Der Teardown wird immer ausgeführt, egal ob der Test bestanden hat, fehlgeschlagen ist oder eine Exception (Ausnahme) ausgelöst hat. Eine fehlschlagende Assertion mittendrin kann die Umgebung also nicht in einem schmutzigen Zustand hinterlassen. Ein manuelles os.environ[...] = ..., gefolgt von einer Aufräumzeile am Ende des Tests, würde genau durch eine solche fehlschlagende Assertion übersprungen werden.
setattr — eine Funktion für einen Test ersetzen
Umgebungsvariablen sind der einfache Fall. Schwieriger wird es bei Code, der über das Netzwerk kommuniziert:
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"Die interessante Logik steckt in advice: die Schwellenwerte, die Meldungen. Testest du die Funktion so, wie sie ist, hängt der Test von einem Server, einer Netzwerkverbindung und dem heutigen Wetter ab. Auf einem Rechner ohne Internetverbindung passiert Folgendes:
$ 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 tauscht fetch_temperature für die Dauer eines Tests gegen etwas aus, das du kontrollierst:
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.01sEs gibt zwei Möglichkeiten, das Ziel anzugeben, und beide bewirken dasselbe:
setattr(weather, "fetch_temperature", new)— das Objekt, dann der Attributname als String.setattr("weather.fetch_temperature", new)— ein String mit Punkt-Notation; pytest importiertweatherund trennt den letzten Teil ab.
Verwende die Objektform, wenn der Test das Modul ohnehin importiert; verwende die Stringform, wenn das nicht nötig ist. Achte in beiden Fällen darauf, was jetzt eigentlich getestet wird: nicht fetch_temperature, das wir ersetzt haben, sondern die Schwellenwerte und Meldungen in advice — also der Code, den wir selbst geschrieben haben. Kein Netzwerk, keine Wartezeit, jedes Mal das gleiche Ergebnis — und auf Abruf eine Temperatur von 35 oder 4 Grad, die das echte Wetter niemals auf Kommando liefern würde.
Warum setattr und nicht einfach weather.fetch_temperature = lambda city: 35 im Test? Aus demselben Grund wie bei os.environ: Die einfache Zuweisung wird nie rückgängig gemacht, und jeder spätere Test im Durchlauf würde das Fake-Objekt erhalten.
Die Regel: Patche dort, wo der Name nachgeschlagen wird
Nun zu dem Fehler, den jeder Entwickler genau einmal macht. Ein kleines Modul, das würfelt:
dice.py:
from random import randint
def roll():
return randint(1, 6)
def is_jackpot():
return roll() == 6Um den Jackpot zu testen, erzwinge, dass randint eine 6 zurückgibt. Die Funktion stammt aus random, also patche sie dort:
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.01sDer Patch wurde angewendet, aber dice hat ihn nie gesehen. Führst du den Test noch ein paar Mal aus, wird er ab und zu bestehen — nämlich immer dann, wenn das echte randint zufällig eine 6 würfelt. Ein Test, der fehlerhaft ist und gelegentlich grün wird, ist schlimmer als einer, der immer rot ist.
Der Grund dafür liegt darin, was from random import randint tatsächlich tut. Es erstellt keine Verknüpfung zu random.randint. Es kopiert die Referenz bereits beim Importieren in einen neuen Namen: dice.randint. Danach gibt es zwei Namen, die auf dieselbe Funktion zeigen, und setattr ändert nur einen davon:
import random
import dice
print(dice.randint is random.randint)
random.randint = lambda a, b: 6
print(dice.randint is random.randint)True
FalseWenn roll() ausgeführt wird, schlägt Python randint im Modul dice nach — genau dort wird der Name nachgeschlagen. Also muss er auch genau dort ersetzt werden:
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.01sFünf Durchläufe hintereinander, fünfmal bestanden. Die andere Lösung besteht darin, die Art und Weise zu ändern, wie dice.py importiert. Mit import random und einem Aufruf von random.randint(1, 6) findet das Nachschlagen bei jedem Aufruf im Modul random statt — und das Patchen von random.randint funktioniert dann wie erwartet:
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.01sDie Regel in einem Satz: Finde die Zeile, die die Funktion aufruft, schau nach, aus welchem Modul sie den Namen bezieht, und patche den Namen genau dort.
setitem — ein einzelner Schlüssel eines Dictionaries
Einstellungen werden oft in einem Dictionary auf Modulebene abgelegt. Das gesamte Dictionary auszutauschen wäre unhandlich; setitem ändert einen einzelnen Schlüssel und stellt ihn hinterher wieder her:
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 ===============================Der dritte Test liefert wieder den Beweis: Nachdem zwei Tests jeweils einen Schlüssel geändert haben, befindet sich das Dictionary wieder exakt im Ausgangszustand. Das Gegenstück, monkeypatch.delitem(d, key), entfernt einen Schlüssel für die Dauer eines Tests.
chdir und syspath_prepend
Code, der einen relativen Pfad wie "notes.txt" öffnet, hängt von dem Verzeichnis ab, aus dem heraus er ausgeführt wird. Kombiniere chdir mit tmp_path aus dem letzten Kapitel:
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 wechselt das Arbeitsverzeichnis für einen einzelnen Test und setzt es danach wieder zurück. Der zweite Test zeigt den typischen Einzeiler für syspath_prepend: Es setzt einen Ordner an den Anfang von sys.path, sodass ein dort abgelegtes Modul importiert werden kann, und entfernt ihn am Ende wieder — sehr praktisch beim Testen von Code, der Plugins aus einem Verzeichnis lädt.
Noch besser: Die Abhängigkeit übergeben
Patchen funktioniert, aber jeder Patch ist ein kleines Eingeständnis dafür, dass der Code nach außen gegriffen und sich etwas aus der Welt geholt hat. Manche Dinge lassen sich ohnehin nicht ohne Weiteres patchen. datetime.now ist ein solches Beispiel:
E TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'Die Alternative ist eine Architekturentscheidung: Lass die Funktion das, wovon sie abhängt, als Argument entgegennehmen — mit dem echten Wert als Standard:
shop_hours.py:
from datetime import datetime
def is_open(now=datetime.now):
hour = now().hour
return 9 <= hour < 21Produktiver Code ruft is_open() auf und erhält die echte Uhr. Ein Test übergibt seine eigene Uhr:
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.01sKeine Fixture, nichts zurückzusetzen, keine Frage danach, wo ein Name nachgeschlagen wird. Der Test liest sich wie ein einfacher Satz — „um 8:59 Uhr hat das Geschäft geschlossen“ — und die entscheidenden Grenzminuten lassen sich exakt ansteuern.
Dasselbe Prinzip funktioniert auch bei Zufallszahlen: def pick_winner(customers, choose=random.choice) — und der Test übergibt choose=lambda items: items[0]. Das nennt man Dependency Injection, und dafür ist kein Framework nötig: Ein Standardargument reicht völlig aus. Nutze dieses Muster, wenn du den Code selbst schreibst. Greife zu monkeypatch, wenn du Code testest, den du nicht ändern kannst oder sollst, oder wenn die Abhängigkeit mehrere Aufrufebenen tief vergraben liegt.
Ein vollständiges Beispiel
Das ist der Konfigurationslader, den wir im Abschnitt „Bevor du den Test schreibst“ geplant haben: drei Ebenen — eingebaute Standardwerte, eine optionale app.cfg-Datei im aktuellen Verzeichnis und Umgebungsvariablen, die beides überschreiben. Jede Zeile des Plans wird zu einem 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 ===============================Drei Punkte verdienen besondere Aufmerksamkeit.
Erstens: Die clean_env-Fixture verwendet monkeypatch selbst. Eine Fixture und der Test, der sie anfordert, teilen sich dasselbe monkeypatch-Objekt. Der Test kann also zusätzlich eigene setenv-Aufrufe hinzufügen, und alle Änderungen beider Seiten werden am Ende gemeinsam rückgängig gemacht.
Zweitens: Die Fixture entfernt die Variablen aktiv, statt sich darauf zu verlassen, dass sie nicht existieren. Das sorgt dafür, dass die Testsuite mit APP_PORT=5 APP_REGION=xx pytest -q exakt dasselbe Ergebnis liefert wie ohne — probiere es aus: Es heißt immer noch 6 passed. Das ist die letzte Zeile unseres Plans, erfüllt durch sauberes Design statt durch einen separaten Test.
Drittens: Der letzte Test patcht app_config.read_file — also das Modul, in dem load_config den Namen nachschlägt. Da beide Funktionen im selben Modul liegen, ist das genau die entscheidende Stelle.
Wenn etwas schiefgeht
AttributeError: <module 'weather' from '.../weather.py'> has no attribute 'fetch_temprature' Ein Tippfehler im Attributnamen. setattr weigert sich, ein Attribut zu erstellen, das noch nicht existiert. Das schützt dich davor, einen Namen zu „patchen“, den niemand jemals liest. Die Stringform formuliert es etwas anders — AttributeError: 'module' object at weather has no attribute 'fetch_temprature' —, meint aber genau dasselbe. Übergib raising=False nur dann, wenn du wirklich beabsichtigst, ein neues Attribut hinzuzufügen.
Der Patch ist aktiv, aber die echte Funktion wird trotzdem ausgeführt Überhaupt keine Fehlermeldung: Der Test schlägt mit dem echten Wert fehl oder besteht durch reines Glück. Du hast den Namen dort gepatcht, wo er definiert ist (random.randint), statt dort, wo er nachgeschlagen wird (dice.randint). Sieh dir die Importzeile im zu testenden Code an.
KeyError: 'APP_DEBUG' von monkeypatch.delenv Die Variable war nicht gesetzt, und delenv löst standardmäßig einen Fehler aus. Ergänze raising=False. Dasselbe gilt für delitem und delattr.
PytestWarning: Value of environment variable APP_PORT type should be str, but got 9000 (type: int); converted to str implicitly Du hast setenv("APP_PORT", 9000) geschrieben. Umgebungsvariablen sind Text; schreibe "9000".
TypeError: cannot set 'now' attribute of immutable type 'datetime.datetime' Bei eingebauten Typen wie datetime.datetime können Methoden nicht ersetzt werden. Übergib die Uhr stattdessen als Argument, wie im is_open-Beispiel gezeigt.
ScopeMismatch: You tried to access the function scoped fixture monkeypatch with a module scoped request object. Eine Fixture mit scope="module" (oder "session") hat monkeypatch angefordert, das jedoch nur für einen einzelnen Test existiert. Belasse die Fixture beim Function Scope oder verwende pytest.MonkeyPatch.context() innerhalb der übergeordneten Fixture und verwalte das Zurücksetzen selbst.
Schritt 4 von 6 — Vorhersagen
Prüfe dein Verständnis
Führe den Test mit pytest -q -s aus. Was zeigen die beiden print-Aufrufe (CITY ist zuvor nicht gesetzt)?
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, dann ein Fehler: KeyError: 'CITY'
- Bnone, dann none
- CDhaka, dann none
- DDhaka, dann Dhaka
Dieser Test schlägt meistens fehl und besteht nur ab und zu. Was stimmt nicht?
# 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- AEs wird `setitem` statt `setattr` benötigt
- BDas `lambda` sollte keine zwei Parameter annehmen
- C`monkeypatch` kann keine Module der Standardbibliothek patchen
- DDer Patch liegt auf `random`, aber `roll()` schlägt den Namen in `dice` nach — patche `dice.randint`
Welche Zeile ersetzt ... am besten, sodass spätere Tests wieder 0.15 sehen?
# 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
Zum Antworten brauchst du ein Konto
Melde dich an, um deine Antworten zu prüfen
Die Fragen stehen oben, und sie im Kopf durchzuarbeiten ist der wichtige Teil. Melde dich an, um die Antworten, die Erklärungen und die dreistufigen Hinweise zu sehen.
Jetzt bist du dran
Schreibe greeter.py mit einer Funktion greeting(name), die Folgendes zurückgibt:
"Good morning, <name>"vor 12:00 Uhr,"Good afternoon, <name>"von 12:00 bis 18:00 Uhr und"Good evening, <name>"danach- in Großbuchstaben, wenn die Umgebungsvariable
GREETER_LOUDauf"1"gesetzt ist
sowie ein Dictionary auf Modulebene TEMPLATES = {"morning": "Good morning", ...}, aus dem die Funktion ihre Textbausteine liest.
Schreibe dann test_greeter.py, das:
setenvunddelenv(..., raising=False)verwendet, um die lauten und leisen Fälle zu testen, und unabhängig davon besteht, obGREETER_LOUDin deiner Shell gesetzt ist oder nicht- mit
setitemdas morgendliche Begrüßungswort für einen einzelnen Test auf"Shubho shokal"ändert und einen zweiten Test enthält, der beweist, dassTEMPLATESdanach wieder den Normalzustand hat - die Zeitgrenzen testet — 11:59, 12:00, 17:59, 18:00
Schreibe es für Schritt 3 zweimal. Zuerst mit from datetime import datetime in greeter.py und versuche monkeypatch.setattr(datetime, "now", ...) — merke dir genau, welchen Fehler du erhältst. (Den gesamten Namen greeter.datetime durch eine eigene kleine Fake-Klasse zu ersetzen funktioniert zwar; beachte aber, wie viel Code das erfordert.) Ändere greeting anschließend so ab, dass es now=datetime.now akzeptiert, und teste die Funktion, indem du eine Uhr übergibst.
Welche der beiden Varianten würdest du lieber behalten? Die zweite ist die Antwort, die fast alle erfahrenen Tester geben — und nachdem du beides selbst ausprobiert hast, wirst du aus eigener Erfahrung verstehen, warum das so ist, und nicht nur durch das Lesen dieser Seite.
Lösung
Beginne mit dem Plan wie im Abschnitt „Bevor du den Test schreibst“. Die verdeckten Eingaben von greeting sind die Uhr, die Variable GREETER_LOUD und das Dictionary TEMPLATES. Die Fälle: die vier Grenzminuten, leise und laut, ein geändertes Template sowie die Prüfung, dass das Template wieder zurückgesetzt wurde.
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 ===============================Und mit der exportierten Variable in der Shell, GREETER_LOUD=1 pytest -q:
........ [100%]
8 passed in 0.01sDie Überlegungen dahinter:
- Die Uhr ist ein Argument, kein Patch. Der erste Versuch,
monkeypatch.setattr(datetime, "now", ...), bricht mitTypeError: cannot set 'now' attribute of immutable type 'datetime.datetime'ab. Statt eine eigene Fake-datetime-Klasse zu bauen, akzeptiertgreetingeinfachnow=datetime.now. Im Produktivcode schreibt man wie gewohntgreeting("Rina")und merkt keinen Unterschied; Tests hingegen übergeben einen exakten Zeitpunkt. at(hour, minute)baut die Uhr. Ein kleiner Helfer beschränkt jeden Test auf eine einzige lesbare Zeile. Er gibt eine Funktion zurück, dagreetingnow()aufruft — die direkte Übergabe einesdatetime-Objekts würde mitTypeError: 'datetime.datetime' object is not callablefehlschlagen.- Grenzen immer paarweise. 11:59 und 12:00, 17:59 und 18:00: jeweils eine Minute vor und nach dem Grenzwert. Schreibt jemand versehentlich
hour <= 12, schlägt der Fall 12:00 sofort an. quietist eine Fixture, keine bloße Annahme. Jeder Test, dessen erwarteter Text gemischte Groß-/Kleinschreibung verwendet, fordert sie an, weil er darauf angewiesen ist, dassGREETER_LOUDnicht gesetzt ist. Deshalb liefert auch der zweite Durchlauf mit der exportierten Variable acht bestandene Tests.test_loudbenötigt die Fixture nicht — dort wird die Variable explizit selbst gesetzt.setitem, danach der Beweis.test_custom_morning_wordändert einen einzelnen Schlüssel;test_templates_are_backfordert keinerlei Fixture an und bestätigt, dass der ursprüngliche Schlüssel wieder da ist — das Rückgängigmachen wird so unmittelbar sichtbar.- Nicht getestet: dass
str.upper()funktioniert oder dassdatetime.now()die aktuelle Uhrzeit liefert. Beides gehört zu Python.
Schritt 6 von 6
Herausforderung — das Kapitel-Quiz
Zehn Fragen von leicht bis schwer. Die letzten sind absichtlich knifflig.
Melde dich an, um das Quiz zu machen