Zum Inhalt springen

cat posts/determinismus-testen.md

Determinismus testen: Zufall, Zeit & I/O kontrollieren

Zufall, Zeit und I/O machen Tests schnell unzuverlässig. Wie Du externe Einflüsse per Dependency Injection, monkeypatch und tmp_path kontrollierst und Deine Logik deterministisch prüfst.

In Agent im Loop (Teil 2) machte eine injizierte Zufallsquelle die Würfel-Engine exakt testbar. Das Muster dahinter ist allgemeiner: Sobald das Ergebnis einer Funktion von Zufall, der aktuellen Uhrzeit oder dem Zustand des Dateisystems abhängt, hängt auch der Test von seiner Umgebung ab.

Das macht solchen Code nicht untestbar. Du musst nur die Dinge unter Kontrolle bringen, die sich außerhalb Deiner eigentlichen Logik befinden.

Das Problem: versteckte Abhängigkeiten

random.randint(...), datetime.now() und open(...) sehen zunächst harmlos aus. Sie greifen aber auf Zustand zu, den die Funktion nicht über ihre Argumente bekommt:

  • der Zufallszahlengenerator hat einen internen State,
  • die Uhr läuft während des Tests weiter,
  • Dateien können existieren, fehlen oder andere Inhalte haben.

Bei open(...) ist also nicht der Funktionsaufruf selbst zufällig. Das Problem ist der externe Zustand, auf den er zugreift.

Eine Funktion wie diese entscheidet selbst, woher ihr Zufall kommt:

import random


def wuerfel():
    return random.randint(1, 6)

Der Test kann nur prüfen, ob das Ergebnis irgendwo zwischen 1 und 6 liegt. Ob sich die Logik für eine gewürfelte 1 oder 6 richtig verhält, lässt sich damit nicht gezielt erzwingen.

Besser ist es, die Abhängigkeit sichtbar zu machen.

Zufall injizieren

Die Funktion kann weiterhin selbst einen sinnvollen Default verwenden. Für Tests darf der Aufrufer aber eine andere Zufallsquelle mitbringen:

import random


def wuerfel(rng=None):
    if rng is None:
        rng = random

    return rng.randint(1, 6)

Damit kannst Du einen eigenen Random-Generator mit festem Seed verwenden:

def test_zufall_mit_seed_reproduzierbar():
    rng_a = random.Random(1)
    rng_b = random.Random(1)

    wuerfe_a = [wuerfel(rng_a) for _ in range(5)]
    wuerfe_b = [wuerfel(rng_b) for _ in range(5)]

    assert wuerfe_a == wuerfe_b

Das ist praktisch, wenn Du reproduzierbare Testdaten brauchst. Ein Seed ist aber noch kein besonders guter Weg, um einen konkreten fachlichen Fall zu testen. Dann müsste der Test wissen, welche Folge randint() für einen bestimmten Seed erzeugt.

Für exakte Erwartungen ist ein kleiner Fake besser:

class FakeRng:
    def __init__(self, *werte):
        self._werte = iter(werte)

    def randint(self, start, ende):
        wert = next(self._werte)
        assert start <= wert <= ende
        return wert


def test_wuerfel_mit_festem_ergebnis():
    rng = FakeRng(6)

    assert wuerfel(rng) == 6

Jetzt bestimmt der Test ausdrücklich: Der nächste Wurf ist eine 6. Genau dieses Muster war für die Würfel-Engine in Teil 2 nützlich, weil sich damit gezielt bestimmte Würfe wie [4, 1, 6] erzwingen lassen.

Ein Seed eignet sich für reproduzierbaren Zufall. Ein Fake eignet sich für vorherbestimmten Zufall.

Zeit injizieren

Bei Zeit gilt dasselbe Prinzip. Statt die aktuelle Uhrzeit tief in der Logik zu holen, kannst Du den relevanten Zeitpunkt hereinreichen:

from datetime import datetime


def ist_wochenende(now=None):
    if now is None:
        now = datetime.now()

    return now.weekday() >= 5

Der normale Aufrufer muss nichts weiter tun:

ist_wochenende()

Der Test dagegen kontrolliert die Zeit vollständig:

def test_samstag_ist_wochenende():
    now = datetime(2026, 9, 19)

    assert ist_wochenende(now)


def test_montag_ist_kein_wochenende():
    now = datetime(2026, 9, 21)

    assert not ist_wochenende(now)

Damit hängt der Test weder vom Wochentag noch davon ab, wann er gestartet wird.

Wenn eine Funktion während ihrer Ausführung mehrfach auf die Uhr schauen muss, kannst Du statt eines fertigen datetime auch eine Clock-Funktion injizieren:

def vergangene_sekunden(start, clock):
    return clock() - start

Der Test liefert dann eine Funktion zurück, die immer denselben kontrollierten Wert liefert. Welche Form sinnvoller ist, hängt davon ab, ob Deine Logik einen Zeitpunkt braucht oder tatsächlich eine laufende Uhr.

Bei Anwendungen mit Zeitzonen solltest Du außerdem bewusst mit timezone-aware datetime-Objekten arbeiten. Die Dependency Injection ändert daran nichts – sie macht nur die Zeitquelle kontrollierbar.

Wenn Du nicht injizieren kannst: monkeypatch

Nicht jede Abhängigkeit lässt sich sinnvoll als Parameter durch die Anwendung reichen. Vielleicht testest Du bestehenden Code, willst eine Umgebungsvariable verändern oder möchtest für einen einzelnen Test eine globale Abhängigkeit ersetzen.

Dafür bringt pytest die Fixture monkeypatch mit.

Angenommen, kalender.py enthält:

from datetime import datetime


def ist_wochenende():
    return datetime.now().weekday() >= 5

Dann kannst Du im Test genau den Namen ersetzen, den dieses Modul verwendet:

from datetime import datetime

import kalender


def test_zeit_per_monkeypatch(monkeypatch):
    class FesteZeit(datetime):
        @classmethod
        def now(cls, tz=None):
            return cls(2026, 9, 19)

    monkeypatch.setattr(kalender, "datetime", FesteZeit)

    assert kalender.ist_wochenende()

Der wichtige Punkt ist kalender.datetime. Gepatcht wird dort, wo der getestete Code den Namen nachschlägt.

Hätte kalender.py stattdessen

import datetime

verwendet und später datetime.datetime.now() aufgerufen, sähe auch der Patch anders aus.

monkeypatch setzt die Änderung nach dem Test automatisch zurück. Es eignet sich neben Attributen auch für Environment-Variablen, Dictionaries, das aktuelle Arbeitsverzeichnis oder sys.path.

Für Code, den Du selbst entwirfst, sind explizite Abhängigkeiten oft leichter zu verstehen und robuster gegenüber Refactorings. monkeypatch ist aber keineswegs nur ein letzter Ausweg – bei Prozesszustand wie Environment-Variablen ist es häufig genau das passende Werkzeug.

Für umfangreichere Tests mit eingefrorener Zeit gibt es außerdem Bibliotheken wie freezegun.

Dateien und I/O: tmp_path

Bei Dateizugriffen musst Du open() meistens gar nicht mocken. Oft ist es sinnvoller, den echten Dateizugriff zu testen und nur dessen Umgebung zu kontrollieren.

pytest stellt dafür tmp_path bereit:

def zaehle_nichtleere_zeilen(pfad):
    with open(pfad, encoding="utf-8") as f:
        return sum(1 for zeile in f if zeile.strip())


def test_datei_mit_tmp_path(tmp_path):
    datei = tmp_path / "log.txt"
    datei.write_text("a\n\nb\n", encoding="utf-8")

    assert zaehle_nichtleere_zeilen(datei) == 2

tmp_path ist ein pathlib.Path und zeigt für jeden Test auf ein eigenes temporäres Verzeichnis. Der Test arbeitet damit tatsächlich mit dem Dateisystem: open() öffnet eine echte Datei, Encoding und Zeilenumbrüche verhalten sich wie im produktiven Code.

Trotzdem kollidiert der Test nicht mit irgendwelchen Dateien im Projektverzeichnis und hinterlässt dort keinen Müll.

pytest verwaltet diese Verzeichnisse selbst. Standardmäßig bleiben die temporären Verzeichnisse der letzten Testläufe sogar erhalten, was bei der Fehlersuche praktisch sein kann. Du solltest Dich also nicht darauf verlassen, dass eine Datei unmittelbar nach dem Test physisch verschwunden ist.

Auch hier steckt eine Form von Dependency Injection dahinter: Die Funktion entscheidet nicht selbst über /tmp/test.txt oder irgendeinen Pfad im Home-Verzeichnis. Der Pfad kommt von außen.

Was sollte man eigentlich injizieren?

Nicht jede Funktion braucht plötzlich fünf zusätzliche Parameter. Dependency Injection ist kein Selbstzweck.

Die interessante Stelle ist die Grenze zwischen Deiner Logik und der Außenwelt:

Logik ───── Zufall
      ├──── Uhr
      ├──── Dateisystem
      ├──── Netzwerk
      └──── Datenbank

Dort lohnt sich eine kontrollierbare Schnittstelle.

Eine Funktion, die zwei Zahlen addiert, braucht keine injizierte Clock. Eine Würfel-Engine braucht aber eine kontrollierbare Zufallsquelle, wenn Du bestimmte Würfe gezielt testen willst. Und eine Funktion, die eine Konfigurationsdatei verarbeitet, profitiert davon, wenn ihr der Pfad übergeben wird, statt irgendwo fest verdrahtet zu sein.

Stolperfallen

  • Ein Seed ist nicht dasselbe wie ein Fake. Ein Seed macht eine Zufallsfolge reproduzierbar. Wenn Dein Test einen ganz bestimmten Wert braucht, gib ihm diesen Wert direkt über einen Fake.
  • Patche dort, wo der Name verwendet wird. monkeypatch auf dem falschen Modul kann wirkungslos bleiben oder unnötig viel Code beeinflussen.
  • Mocke I/O nicht reflexartig. Für lokale Dateien liefert tmp_path oft den realistischeren und trotzdem isolierten Test.
  • Injiziere an sinnvollen Grenzen. Eine Abhängigkeit durch zehn Funktionen durchzureichen, obwohl nur eine davon sie benötigt, macht den Code nicht automatisch besser.
  • Teste Deine Logik, nicht Python. Du musst nicht beweisen, dass datetime.weekday() oder random.randint() funktionieren. Interessant ist, was Dein Code mit deren Ergebnissen macht.

Der Kern ist simpel: Ein Test sollte selbst bestimmen können, welche äußeren Einflüsse seine Logik sieht. Dann wird aus „hoffentlich tritt dieser Fall irgendwann auf“ ein gezieltes assert.

Weiterlesen

Sources: pytest: monkeypatch, pytest: tmp_path, Python: random, Python: datetime, freezegun.

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!