Zum Inhalt springen
aviral gupta

// 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.

Lektion 2 von 5 in I5 Tests und Projektwerkzeuge

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
  1. Aufwärmen · Aufgabe 1 von 7

    Aufwärmen aus der vorigen Lektion: Welche Aussagen über unittest stimmen? Wählen Sie alle zutreffenden.

    Wählen Sie alle zutreffenden aus.

  2. 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)
  3. Ü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")
  4. Ü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")
  5. Üben · Aufgabe 5 von 7

    Ordnen Sie jeder Einstellung eines Mocks zu, was ein Aufruf bewirkt.

  6. 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))
  7. 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.py

Ausgabe

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
  1. Hinweis 1

    Setzen Sie den ersten Aufruf in try und except ConnectionError. Der except-Block macht den zweiten Versuch.

  2. Hinweis 2

    Der zweite Versuch braucht ein eigenes try und except ConnectionError, das "offline" liefert.

  3. 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.py

Prüfungen ausführen (learnrun.py muss im selben Ordner liegen):

python learnrun.py test
learnrun.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
  1. 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".

  2. Hinweis 2

    with patch(...) as fake: gibt Ihnen den Mock. Nach dem Block: fake.assert_called_once_with(7).

  3. 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.py

Prüfungen ausführen (learnrun.py muss im selben Ordner liegen):

python learnrun.py test
learnrun.py herunterladen

Hä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 2

Warum, 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.

Problem melden

Etwas ist falsch oder unklar? Beschreiben Sie es kurz, dann wird es geprüft und korrigiert.

#

Mindestens 20 Zeichen.

Nur, wenn Sie eine Antwort wünschen.

Kernideen

Ein Mock springt für alles ein

from unittest.mock import Mock liefert ein Objekt, das jeden Aufruf und fast jedes Attribut annimmt: m.save, m.load.data und m(1, 2) funktionieren alle und liefern jeweils einen weiteren Mock. Namen mit zwei Unterstrichen an beiden Enden, etwa __len__, lösen AttributeError aus. Was er tut, bestimmen Sie. m.return_value = 3 lässt jeden Aufruf 3 liefern. m.side_effect geht weiter: Eine Ausnahmeklasse oder -instanz wird beim Aufruf ausgelöst, ein Iterable gibt bei jedem Aufruf seinen nächsten Wert heraus, und eine Funktion wird mit denselben Argumenten aufgerufen und ihr Ergebnis zurückgegeben.

patch ersetzt einen Namen für einen Test

patch("module.name") tauscht einen Namen in einem Modul gegen einen Mock, und wenn der with-Block oder der dekorierte Test endet, kommt das Original zurück. Als Dekorator übergibt @patch den Mock dem Test als zusätzliches Argument. Patchen Sie dort, wo der Name nachgeschlagen wird, nicht wo er definiert ist: Enthält shop.py from rates import fetch_rate, hat shop einen eigenen Namen, und das Ziel heißt "shop.fetch_rate". "rates.fetch_rate" zu patchen, lässt die Kopie in shop unberührt.

Prüfen, wie der Mock aufgerufen wurde

Ein Mock zeichnet jeden Aufruf auf. m.called sagt, ob er überhaupt aufgerufen wurde, m.call_count wie oft, und m.call_args enthält die Argumente des letzten Aufrufs, angezeigt als call(2, key='x'). Die assert-Methoden lassen den Test mit klarer Meldung scheitern: assert_called_once_with(args) verlangt genau einen Aufruf mit diesen Argumenten, assert_called_with prüft nur den letzten Aufruf, assert_any_call irgendeinen und assert_not_called keinen. Ein Name, der mit assert oder einer häufigen Verschreibung davon beginnt (assret, asert, aseert, assrt), muss eine echte assert-Methode sein, sonst folgt AttributeError. Andere Tippfehler, etwa asset_called_once_with, bestehen still.

Quellen

Zuletzt geprüft am 29. September 2026