Aufwärmen · Aufgabe 1 von 7
// I5.2 · ca. 30 Min. · Aufbau
Testdoubles mit unittest.mock
Nach dieser Lektion ersetzen Sie eine langsame oder unzuverlässige Abhängigkeit durch einen Mock, lassen ihn Werte liefern oder Fehler auslösen, patchen ihn im richtigen Modul und prüfen, wie Ihr Code ihn aufrief.
Danach können Sie
- Einen Mock anlegen und mit return_value und side_effect einstellen
- Eine Abhängigkeit im Test mit patch ersetzen, und zwar dort, wo der Name nachgeschlagen wird
- Mit called, call_count, call_args und assert_called_once_with prüfen, wie ein Mock aufgerufen wurde
Vorhersagen · Aufgabe 2 von 7
Sagen Sie es vorher, bevor Sie weiterlesen: weather ist ein Mock, kein echter Dienst. Was gibt das aus?
from unittest.mock import Mock weather = Mock() weather.today.return_value = "sunny" print(weather.today("Berlin"), weather.today.call_count)Üben · Aufgabe 3 von 7
Setzen Sie das Attribut ein, damit ein Aufruf von api.fetch ConnectionError("offline") auslöst, wie es ein echter Netzwerkaufruf tun könnte.
api.fetch.____ = ConnectionError("offline")api.fetch. = ConnectionError("offline")Üben · Aufgabe 4 von 7
side_effect ist eine Liste. Was gibt das aus?
from unittest.mock import Mock m = Mock(side_effect=[1, 2]) print(m(), m()) try: m() except StopIteration: print("no values left")Üben · Aufgabe 5 von 7
Ordnen Sie jeder Einstellung eines Mocks zu, was ein Aufruf bewirkt.
Denksport · Aufgabe 6 von 7
Knobelaufgabe. Der Code patcht math.floor. Was gibt er aus?
import math from math import floor from unittest.mock import patch with patch("math.floor", return_value=99): print(math.floor(1.5), floor(1.5))Anwenden · Aufgabe 7 von 7
Kleine Aufgabe. send_welcome(email, mailer) prüft die Adresse und ruft mailer.send(email, "Welcome!") auf. Testen Sie die Funktion, ohne Mails zu verschicken: Übergeben Sie einen Mock als mailer. Ein Test prüft, dass send einmal mit den richtigen Argumenten aufgerufen wurde; ein anderer, dass eine Adresse ohne @ ValueError auslöst und nichts verschickt.
Prüfen Sie Ihr Ergebnis anhand dieser Liste
Selbst programmieren
Lesen Sie das ausgearbeitete Beispiel und lösen Sie dann die Übungen. Ihr Code läuft in Ihrem Browser oder auf Ihrem Computer und wird nie hochgeladen.
Ausgearbeitetes Beispiel
Preise ohne Netzwerk
shop.price_in rechnet einen Preis mit einem Wechselkurs aus rates.fetch_rate um, das das Netzwerk bräuchte. Die Tests patchen den Kurs: einmal als Dekorator mit festem return_value, einmal als Kontextmanager mit side_effect=ConnectionError. Der dritte Test zeigt die Falle: Er patcht rates.fetch_rate, aber shop hat einen eigenen Namen importiert, also läuft die echte Funktion und löst RuntimeError aus.
main.py
import unittest
from unittest.mock import patch
import shop
class TestPriceIn(unittest.TestCase):
@patch("shop.fetch_rate", return_value=1.1)
def test_converts(self, fake_rate):
self.assertEqual(shop.price_in(1000, "USD"), "11.00 USD")
fake_rate.assert_called_once_with("USD")
def test_offline(self):
with patch("shop.fetch_rate", side_effect=ConnectionError):
self.assertEqual(shop.price_in(1000, "USD"), "price unavailable")
def test_wrong_target(self):
# rates.fetch_rate is replaced, but shop already holds its own name
with patch("rates.fetch_rate", return_value=1.1):
with self.assertRaises(RuntimeError):
shop.price_in(1000, "USD")
suite = unittest.TestLoader().loadTestsFromTestCase(TestPriceIn)
result = unittest.TestResult()
suite.run(result)
print("tests run:", result.testsRun)
print("all passed:", result.wasSuccessful())
print("after the tests, shop.fetch_rate is a", type(shop.fetch_rate).__name__)
shop.py
from rates import fetch_rate
def price_in(cents: int, currency: str) -> str:
"""A price in euro cents, shown in another currency."""
try:
rate = fetch_rate(currency)
except ConnectionError:
return "price unavailable"
return f"{cents * rate / 100:.2f} {currency}"
rates.py
def fetch_rate(currency: str) -> float:
"""Would ask a web service for today's exchange rate."""
raise RuntimeError("no network access in tests")
Ausführen mit
python main.pyAusgabe
tests run: 3
all passed: True
after the tests, shop.fetch_rate is a function- Der Dekorator übergibt test_converts den Mock als fake_rate, mit dem der Test den Aufruf prüft.
- side_effect=ConnectionError lässt den Mock auslösen; so wird der Offline-Zweig von price_in getestet, ohne ein Netzwerkkabel zu ziehen.
- test_wrong_target besteht nur, weil er den RuntimeError des echten fetch_rate erwartet.
- Die letzte Zeile zeigt, dass jeder Patch rückgängig gemacht wurde: shop.fetch_rate ist wieder die echte Funktion.
Ändern und ausführen
Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.
Beim ersten Ausführen lädt Ihr Browser Python herunter (bis zu 6.5 MB) und speichert es im Cache. Ihr Code bleibt auf Ihrem Gerät.
Übungen
Übung 1 von 2
Einmal wiederholen
Schreiben Sie fetch_with_retry(fetch, url). Die Funktion ruft fetch(url) auf und liefert das Ergebnis. Löst fetch ConnectionError aus, versucht sie es genau einmal erneut; scheitert auch das, liefert sie "offline". Andere Ausnahmen müssen unverändert durchgehen. Die Tests übergeben Mocks, deren side_effect auslöst oder Werte liefert, und zählen die Aufrufe. Ausführen zeigt ein wackliges fetch, das einmal scheitert.
Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.
Beim ersten Ausführen lädt Ihr Browser Python herunter (bis zu 6.5 MB) und speichert es im Cache. Ihr Code bleibt auf Ihrem Gerät.
Hinweise
Hinweis 1
Setzen Sie den ersten Aufruf in try und except ConnectionError. Der except-Block macht den zweiten Versuch.
Hinweis 2
Der zweite Versuch braucht ein eigenes try und except ConnectionError, das "offline" liefert.
Hinweis 3
Fangen Sie nur ConnectionError: Ein ValueError muss die Funktion nach einem Aufruf verlassen.
Eine Lösung zeigen
Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.
from collections.abc import Callable
from unittest.mock import Mock
def fetch_with_retry(fetch: Callable[[str], str], url: str) -> str:
try:
return fetch(url)
except ConnectionError:
try:
return fetch(url)
except ConnectionError:
return "offline"
if __name__ == "__main__":
flaky = Mock(side_effect=[ConnectionError("down"), "<html>hello</html>"])
print(fetch_with_retry(flaky, "https://example.com"))
print("calls:", flaky.call_count)
Auf dem eigenen Computer ausführen
Installieren Sie Python 3.14 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.
main.py
from collections.abc import Callable
from unittest.mock import Mock
def fetch_with_retry(fetch: Callable[[str], str], url: str) -> str:
# Call fetch(url). If it raises ConnectionError, try exactly once more.
# If the second try fails too, return "offline".
return fetch(url)
if __name__ == "__main__":
flaky = Mock(side_effect=[ConnectionError("down"), "<html>hello</html>"])
print(fetch_with_retry(flaky, "https://example.com"))
print("calls:", flaky.call_count)
test_main.py
from unittest.mock import Mock
from main import fetch_with_retry
def test_first_try():
"""Ein funktionierendes fetch wird einmal aufgerufen und sein Ergebnis geliefert"""
fetch = Mock(return_value="page")
got = fetch_with_retry(fetch, "u")
assert got == "page", f"lieferte {got!r}, erwartet 'page'"
assert fetch.call_count == 1, f"fetch wurde {fetch.call_count}-mal aufgerufen, erwartet 1"
fetch.assert_called_once_with("u")
def test_retry_succeeds():
"""Ein ConnectionError, dann Erfolg: zwei Aufrufe"""
fetch = Mock(side_effect=[ConnectionError("down"), "page"])
got = fetch_with_retry(fetch, "u")
assert got == "page", f"lieferte {got!r}, erwartet 'page'"
assert fetch.call_count == 2, f"fetch wurde {fetch.call_count}-mal aufgerufen, erwartet 2"
def test_offline():
"""Zwei ConnectionErrors ergeben "offline", ohne dritten Versuch"""
fetch = Mock(side_effect=ConnectionError("down"))
got = fetch_with_retry(fetch, "u")
assert got == "offline", f"lieferte {got!r}, erwartet 'offline'"
assert fetch.call_count == 2, f"fetch wurde {fetch.call_count}-mal aufgerufen, erwartet 2"
def test_other_errors_pass():
"""Andere Ausnahmen werden weder gefangen noch wiederholt"""
fetch = Mock(side_effect=ValueError("bad url"))
try:
fetch_with_retry(fetch, "u")
except ValueError:
pass
else:
assert False, "ein ValueError von fetch wurde verschluckt; fangen Sie nur ConnectionError"
assert fetch.call_count == 1, f"fetch wurde nach einem ValueError {fetch.call_count}-mal aufgerufen, erwartet 1"
Unter macOS und Linux tippen Sie python3, wo in diesen Befehlen python steht, wie in der ersten Lektion.
Programm ausführen:
python main.pyPrüfungen ausführen (learnrun.py muss im selben Ordner liegen):
python learnrun.py testlearnrun.py herunterladenÜbung 2 von 2
Den richtigen Namen patchen
greeting.greet(user_id) holt den Benutzer über users.lookup, das eine Datenbank braucht. Vervollständigen Sie TestGreet. Korrigieren Sie das Patch-Ziel, damit der Test einen Mock statt des echten lookup nutzt, und prüfen Sie, dass lookup einmal mit 7 aufgerufen wurde. Ergänzen Sie dann test_unknown_user: lookup löst KeyError aus, und greet liefert "Hello, guest!". Rufen Sie greet als greeting.greet auf. Die Prüfungen lassen Ihre Tests auch gegen fehlerhafte Versionen von greet laufen.
Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.
Beim ersten Ausführen lädt Ihr Browser Python herunter (bis zu 6.5 MB) und speichert es im Cache. Ihr Code bleibt auf Ihrem Gerät.
Hinweise
Hinweis 1
greeting.py enthält from users import lookup, greet schlägt den Namen also in greeting nach. Das Ziel heißt "greeting.lookup".
Hinweis 2
with patch(...) as fake: gibt Ihnen den Mock. Nach dem Block: fake.assert_called_once_with(7).
Hinweis 3
Für den unbekannten Benutzer: with patch("greeting.lookup", side_effect=KeyError(7)): und dann assertEqual auf greeting.greet(7).
Eine Lösung zeigen
Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.
import unittest
from unittest.mock import patch
import greeting
class TestGreet(unittest.TestCase):
def test_known_user(self) -> None:
with patch("greeting.lookup", return_value={"name": "Ada"}) as fake:
self.assertEqual(greeting.greet(7), "Hello, Ada!")
fake.assert_called_once_with(7)
def test_unknown_user(self) -> None:
with patch("greeting.lookup", side_effect=KeyError(7)):
self.assertEqual(greeting.greet(7), "Hello, guest!")
if __name__ == "__main__":
suite = unittest.TestLoader().loadTestsFromTestCase(TestGreet)
unittest.TextTestRunner(verbosity=2).run(suite)
Auf dem eigenen Computer ausführen
Installieren Sie Python 3.14 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.
main.py
import unittest
from unittest.mock import patch
import greeting
class TestGreet(unittest.TestCase):
def test_known_user(self) -> None:
# 1. This patches the wrong name: greeting.py has its own "lookup".
with patch("users.lookup", return_value={"name": "Ada"}) as fake:
self.assertEqual(greeting.greet(7), "Hello, Ada!")
# 2. Check that lookup was called exactly once, with 7.
# 3. Add test_unknown_user: lookup raises KeyError, greet says "Hello, guest!".
if __name__ == "__main__":
suite = unittest.TestLoader().loadTestsFromTestCase(TestGreet)
unittest.TextTestRunner(verbosity=2).run(suite)
test_main.py
import unittest
import greeting
import main
def run_against(implementation):
saved = greeting.greet
greeting.greet = implementation
try:
result = unittest.TestResult()
unittest.TestLoader().loadTestsFromTestCase(main.TestGreet).run(result)
finally:
greeting.greet = saved
return result
def ignores_name(user_id):
greeting.lookup(user_id)
return "Hello, user!"
def no_guest(user_id):
user = greeting.lookup(user_id)
return f"Hello, {user['name']}!"
def wrong_id(user_id):
try:
user = greeting.lookup(user_id + 1)
except KeyError:
return "Hello, guest!"
return f"Hello, {user['name']}!"
def test_correct_code_passes():
"""Ihre Tests bestehen mit dem echten greet"""
result = run_against(greeting.greet)
problems = [trace.strip().splitlines()[-1] for _, trace in result.failures + result.errors]
assert result.wasSuccessful(), f"Ihre Tests scheitern an korrektem Code: {problems}"
assert result.testsRun >= 2, f"{result.testsRun} Tests liefen; schreiben Sie mindestens 2"
def test_catches_ignored_name():
"""Ihre Tests erkennen ein greet, das den Namen des Benutzers übergeht"""
result = run_against(ignores_name)
assert not result.wasSuccessful(), "alle Tests bestehen, obwohl greet immer 'Hello, user!' sagt"
def test_catches_missing_guest():
"""Ihre Tests erkennen ein greet, das bei unbekannten Benutzern abstürzt"""
result = run_against(no_guest)
assert not result.wasSuccessful(), "alle Tests bestehen, obwohl greet den KeyError durchlässt; testen Sie einen unbekannten Benutzer"
def test_catches_wrong_id():
"""Ihre Tests prüfen die id, mit der lookup aufgerufen wurde"""
result = run_against(wrong_id)
assert not result.wasSuccessful(), "alle Tests bestehen, obwohl greet die falsche id nachschlägt; nutzen Sie assert_called_once_with(7)"
greeting.py
from users import lookup
def greet(user_id: int) -> str:
try:
user = lookup(user_id)
except KeyError:
return "Hello, guest!"
return f"Hello, {user['name']}!"
users.py
def lookup(user_id: int) -> dict[str, str]:
"""Would read the user from a database."""
raise RuntimeError("the user database is not available in tests")
Unter macOS und Linux tippen Sie python3, wo in diesen Befehlen python steht, wie in der ersten Lektion.
Programm ausführen:
python main.pyPrüfungen ausführen (learnrun.py muss im selben Ordner liegen):
python learnrun.py testlearnrun.py herunterladenHäufige Fehler
Dort patchen, wo die Funktion definiert ist
from math import floor
from unittest.mock import patch
with patch("math.floor", return_value=0):
result = floor(2.5)
assert result == 0, f"floor was not replaced: got {result}"
Was Python ausgibt
AssertionError: floor was not replaced: got 2Warum, und die Lösung
from math import floor hat diesem Modul einen eigenen Namen floor gegeben, der schon vor dem Patch an die echte Funktion gebunden war. patch("math.floor") ändert nur das Attribut von math. Patchen Sie den Namen dort, wo Ihr Code ihn nachschlägt: In einem Modul mit from math import floor ist das "ihrmodul.floor". Oder schreiben Sie import math und rufen math.floor auf.
Eine assert-Methode ohne assert
from unittest.mock import Mock
mailer = Mock()
mailer.send("ada@example.com")
mailer.send.called_once_with("ada@example.com")
Was Python ausgibt
AttributeError: 'called_once_with' is not a valid assertion. Use a spec for the mock if 'called_once_with' is meant to be an attribute.. Did you mean: 'assert_called_once_with'?Warum, und die Lösung
Ein Mock legt jedes Attribut an, nach dem Sie fragen; eine falsch geschriebene Prüfung wäre also nur ein weiterer Aufruf, der nie scheitern kann. Mock weist einige Namen wie diesen zurück, die nach einem vergessenen assert_ aussehen, und die Meldung nennt am Ende die echte Methode, assert_called_once_with. Prüfen Sie die Schreibweise trotzdem selbst; ein Tippfehler wie asset_called_once_with besteht still.
Ein Ziel ohne Modul
from unittest.mock import patch
def fetch_rate(currency):
raise RuntimeError("no network")
with patch("fetch_rate", return_value=1.1):
print(fetch_rate("USD"))
Was Python ausgibt
TypeError: Need a valid target to patch. You supplied: 'fetch_rate'Warum, und die Lösung
patch braucht einen Pfad mit Punkt, "module.name", damit es das Modul importieren und das Attribut ersetzen kann. Legen Sie den getesteten Code in ein eigenes Modul, etwa shop.py, und patchen Sie "shop.fetch_rate". Um ein Attribut eines Objekts zu ersetzen, das Sie schon in der Hand haben, nutzen Sie patch.object(obj, "name").
Python im Browser: Pyodide 314.0.7, MPL-2.0. Lizenz und Quellcode
Abschlussquiz
5 Fragen, ohne Hinweise. Ab 80 % ist die Lektion abgeschlossen.
Erledigen Sie zuerst alle Aufgaben oben, um das Abschlussquiz freizuschalten.