Senior AWS Consultant
Nur eben schnell etwas Kleines ändern … und plötzlich funktioniert die ganze Anwendung nicht mehr. Was tun Sie dann? Wahrscheinlich stundenlang Logfiles durchforsten oder Workflows mühsam manuell testen – zumindest machen das die meisten so. Aber das muss nicht sein! In diesem Artikel erzähle ich, wie mich testgetriebene Entwicklung (Test-Driven Development, TDD) dazu gebracht hat, die Softwareentwicklung ganz anders anzugehen.
Früher war ich skeptisch gegenüber TDD. Noch vor der ersten Zeile Code erst mal Tests schreiben, das schien mir unlogisch. Wie soll ich etwas testen, das noch gar nicht existiert? Doch die Arbeit an komplexen Projekten hat meine Meinung komplett geändert.
Immer, wenn ich in einer unbekannten Codebasis unterwegs war, diente TDD als mein Kompass. Beim Schreiben der Tests war ich gezwungen, intensiv darüber nachzudenken, was mein Code tun soll. Statt sofort in die Umsetzung zu gehen, musste ich meine Erwartungen klar ausformulieren – wobei sich oft Verständnislücken gezeigt haben, die ich sonst nicht bemerkt hätte. Am Ende führte dieser Prozess immer zu einem besseren Design.
Wo testgetriebene Entwicklung am meisten nützt
TDD ist insbesondere dann eine große Hilfe, wenn man vorhandenen Code ändern muss. Ich muss nicht die ganze Zeit im Blick behalten, wie sich die Änderungen um ein, zwei oder drei Ecken auf das System auswirken könnten, sondern kann man mich darauf verlassen, dass die Testsuite eventuelle Regressionen schon finden wird. Mit diesem Sicherungsnetz kann ich mich ganz auf das Coden konzentrieren, ohne mir Gedanken um unvorhergesehene Konsequenzen machen zu müssen.
Wenn sich die Requirements ändern und ich neue Features hinzufügen muss, gewährleisten meine Tests, dass die vorhandene Funktionalität intakt bleibt.
Der Wendepunkt in meiner Beziehung zu TDD kam bei einem Großprojekt mit einer Codebasis, die ich überhaupt nicht kannte. Jede Änderung hätte irgendwo anders im System unvorhersehbare Folgen haben können.
Beim Schreiben der Tests musste ich genau formulieren, was ich erreichen möchte, und diese Klarheit war Gold wert. Statt einfach direkt loszulegen, habe ich mir erst grundlegende Fragen beantwortet: Was soll diese Funktion zurückgeben? Wie soll mit Edge-Cases umgegangen werden? Welche Abhängigkeiten gibt es?
Noch vor der ersten Zeile Code einen Test zu schreiben, zwingt dich, wichtige Aspekte genau festzulegen:
- Welche Eingaben die Funktion akzeptieren soll
- Welche Abhängigkeiten gemockt werden müssen (wem das nichts sagt: siehe unten)
- Welche Ausgabestruktur erwartet wird
Integrationstests: das richtige Maß finden
Bei Integrationstests wird untersucht, wie Komponenten miteinander interagieren. Alles auf einmal zu testen, wäre aber sehr ineffektiv. Es kommt vielmehr darauf an, die richtige Komponentenkombination zu testen.
Das Ziel von Integrationstests ist nicht, die Ausgabe einer Funktion zu testen, sondern festzustellen, ob ein Workflow auch dann noch erfolgreich abläuft, wenn eine Komponente verändert wird.
Auf diese Komponente sollte sich der Test fokussieren.
Zuerst definieren Sie den Testumfang. Danach legen Sie fest:
- Welche Eingabe für den Workflow benötigt wird
- Welche Ausgabe er generieren soll
- Wie viele Hilfsfunktionen während des Workflows aufgerufen werden
Wenn man beispielsweise den Code für den Warenkorb eines Onlineshops verändert, ist es unerheblich, wie die Artikel in den Warenkorb gelangen oder wie die Zahlung verarbeitet wird. Was zählt, ist die Frage, ob sich die Artikelmenge verändern lässt und ob Artikel gelöscht werden können.
Bei einem Integrationstest geht es nicht primär um Abhängigkeiten oder Drittanbietertools. Deren Ausgaben mockt man bei Bedarf einfach. Dasselbe gilt für Hilfsfunktionen, die nicht zum Testumfang gehören.
TDD ist, wie gesagt, aber auch für bestehende Codebasen hilfreich. Stellen Sie sich vor, Sie haben eine große Funktion aus Hunderten Zeilen mit unzähligen Abhängigkeiten. Bevor Sie direkt anfangen, das neue Feature hinzuzufügen, schreiben Sie einen Integrationstest, um die aktuelle Funktionalität des Codes zu überprüfen. Während Sie die Fehlermeldungen in Ihrem Test-Case durchgehen, mocken Sie die Abhängigkeiten im Code. Nach dem Test haben Sie dann eine Vorlage für den tatsächlichen Test-Case Ihres neuen Features. Alle Abhängigkeiten, die für Ihren Test nicht relevant sind, wurden dann bereits gemockt.
Jetzt können Sie ganz einfach Ihren Code schreiben, und die Testergebnisse sagen Ihnen, worauf Sie ein Auge haben müssen. Damit das neue Feature stabiler wird, simulieren Sie mit Ihrem Test-Case verschiedene Szenarien und überprüfen den Code dann auf Schwachstellen.
Ein Beispiel:
Die Funktion make_cake() ruft drei Unterfunktionen auf: get_ingredients(), make_batter() und bake(). Wir wollen die Funktion make_batter() verändern und prüfen,
# cake_maker.py
def get_ingredients():
"""Get cake ingredients from some external source"""
# In real life, this might call an API or database
return {"flour": 200, "sugar": 150, "eggs": 2, "butter": 100}
def make_batter(ingredients):
"""Mix ingredients to create a batter"""
# This is the function we want to test for real
if not ingredients:
return None
return {
"mixed": True,
"quality": sum(ingredients.values()) / 10
}
def bake(batter):
"""Bake the batter into a cake"""
# Another external function we'll mock
if not batter:
return {"success": False}
return {"success": True, "taste_score": batter["quality"] * 2}
def make_cake():
"""Main workflow function"""
ingredients = get_ingredients()
batter = make_batter(ingredients)
cake = bake(batter)
return cake
Beim Integrationstest mocken wir die Antworten von get_ingredients() und bake(), weil diese beiden Funktionen nicht getestet werden müssen. Wir müssen nur testen, wie sich die Änderungen an make_batter() in make_cake() auswirken, wenn die beiden anderen Funktionen wie erwartet ablaufen.
# test_cake_maker.py
import pytest
from cake_maker import make_cake
def test_make_cake_real_batter_only(mocker):
"""Test make_cake() but only test the make_batter function for real"""
# Mock the ingredients function
mock_ingredients = {"flour": 200, "sugar": 150, "eggs": 2, "butter": 100}
mock_ingr = mocker.patch('cake_maker.get_ingredients', return_value=mock_ingredients)
# Mock the bake function
mock_cake = {"success": True, "taste_score": 90}
mock_bake = mocker.patch('cake_maker.bake', return_value=mock_cake)
# Call the function - this will use our mocked functions but the real make_batter
result = make_cake()
# Verify result
assert result == mock_cake
# Verify the bake function was called with the correct batter
expected_batter = {
"mixed": True,
"quality": sum(mock_ingredients.values()) / 10
}
actual_batter = mock_bake.call_args[0][0]
assert actual_batter["mixed"] == expected_batter["mixed"]
assert actual_batter["quality"] == expected_batter["quality"]
# Verify our mocks were called
mock_ingr.assert_called_once()
mock_bake.assert_called_once()
Wichtige Punkte
- Funktionen mocken, die nicht getestet werden: Wir patchen get_ingredients() und bake(), um make_batter() zu isolieren.
- Die eigentliche Funktion ausführen: make_batter() patchen wir nicht, weil es die Funktion ist, die getestet werden soll.
- Eingaben und Ausgaben prüfen: Wir prüfen, ob die gemockten Funktionen die erwarteten Argumente von der getesteten Funktion bekommen.
- Testlogik einfach halten: Wir müssen sicherstellen, dass sich die Zielfunktion korrekt in den Workflow integriert, darauf liegt der Fokus.
Mit dieser Herangehensweise können Sie testen, wie sich eine bestimmte Komponente in einem System verhält, ohne sich um externe Abhängigkeiten Gedanken machen zu müssen. Das war natürlich ein sehr einfaches Beispiel. In der Praxis hat man es oft mit komplexen Funktionen im Hintergrund zu tun, die vielleicht externe Dienste aufrufen. Die sollte man aus verschiedenen Gründen – Geschwindigkeit, Authentifizierung oder Komplexität – nicht jedes Mal aufrufen.
Mocking: eine Einführung
Ich habe jetzt schon viel über Mocking gesprochen. Für diejenigen, die damit nichts anfangen können, gebe ich hier einen kurzen Überblick.
Mocks und Integrationsteste für TDD
Mocking ist wichtig, um den zu testenden Code zu isolieren. Kurz gesagt, hindert es Ihren Code daran, vorhandene Unterfunktionen aufzurufen. Stattdessen wird ein Dummy zurückgegeben, oft mit einem festen Rückgabewert. Das ist besonders dann hilfreich, wenn Aufrufe an AWS oder andere externe Dienste enthalten sind, für deren Funktionstüchtigkeit Sie natürlich nicht zuständig sind. Es hätte in solchen Fällen keinen Sinn, Code zu testen, den Sie gar nicht ändern können.
Das Plug-in pytest-mock erleichtert diesen Prozess mit dem mocker-Fixture. Es gibt beispielsweise folgende Mocking-Techniken:
Grundlegendes Mocking
def test_user_service(mocker):
# Mock a database call
mock_db_query = mocker.patch('services.db.query_user', return_value = {"id": 1, "name": "John"})
from services import get_user_details
result = get_user_details(1)
assert result["name"] == "John"
mock_db_query.assert_called_once_with(1)
In diesem Beispiel wollen wir die Funktion get_user_details() testen. Diese Funktion ruft eine andere Funktion, query_user(), auf. Da wir query_user() nicht testen wollen, mocken wir sie, damit die Datenbank nicht wirklich abgefragt wird. Durch Patchen der Funktion weisen wir die Testsuite an, die vorhandene Funktion nicht tatsächlich aufzurufen, sondern stattdessen einen Festwert zurückzugeben, wenn die gepatchte Funktion aufgerufen wird.
Mock-Verhalten mit side_effect steuern
Der Parameter side_effect bietet mehr dynamische Kontrolle als return_value:
def test_retry_mechanism(mocker):
# Mock that raises an exception on first call, succeeds on second
mocker.patch('services.external_api.call', side_effect= [ConnectionError("Timeout"), {"data": "success"}])
from services import fetch_with_retry
result = fetch_with_retry("endpoint")
assert result == {"data": "success"}
Das ist auch hilfreich, wenn man mehrere Antworten testen muss, die zu komplex für eine einfache Liste sind.
Attribute mocken statt Rückgabewerte
Manchmal muss man statt einem Rückgabewert ein Attribut mocken. In diesem Beispiel haben wir die Funktion initialize(), die ein Anwendungsobjekt instanziiert, indem sie die Unterfunktion get_config() aufruft, die gewisse Attribute für das Objekt liefert. Hier mocken wir einige Attribute in unserer Config.
def test_configuration(mocker):
from unittest.mock import MagicMock
config = MagicMock()
config.DEBUG = True
config.API_KEY = "test_key"
mocker.patch('app.get_config', return_value=config)
from app import initialize
app = initialize()
assert app.debug_mode is True
Klassenmethoden mocken
Methoden in einer Klasse werden mit patch.object gemockt:
def test_class_method(mocker):
# If you have a class like:
# class MyClass:
# def my_class_func(self):
# return "real result"
from my_module import MyClass
# Mock the class method
mocker.patch.object(MyClass, "my_class_func", return_value="mocked result")
# Now any call to MyClass().my_class_func() will return "mocked result"
instance = MyClass()
assert instance.my_class_func() == "mocked result"
Wo wird gepatcht?
Eine der größten Schwierigkeiten beim Mocken: die richtige Stelle zum Patchen zu finden. Grundsätzlich patcht man die Funktion dort, wo sie importiert wird, nicht, wo sie definiert wird.
modules/my_func.py:
def meine_func():
# Function definition here
return "real result"
scripts/other_func.py:
from modules.my_func import meine_func
def random_func():
var = 2
return meine_func(var)
Wenn wir meine_func mocken wollen, wenn sie aus random_func aufgerufen wird, müssen wir sie an der Importstelle patchen:
def test_meine_func(mocker):
# Patch where the function is imported
mock_func = mocker.patch("scripts.other_func.meine_func", return_value="mocked result")
from scripts.other_func import random_func
result = random_func()
assert result == "mocked result"
mock_func.assert_called_once_with(2)
Mock-Aufrufe untersuchen
Um zu prüfen, wie Mocks aufgerufen wurden, kann man das Attribut mock_calls untersuchen:
def test_check_calls(mocker):
mock_service = mocker.patch("my_module.service_client.update")
# Run the function that should call the service
from my_module import update_user
update_user(user_id=123, name="Alice")
# Check that the mock was called correctly
assert mock_service.call_count == 1
mock_service.assert_called_once_with(user_id=123, name="Alice")
# For more detailed inspection
print(mock_service.mock_calls) # Shows all calls with arguments
Das Attribut mock_calls gibt eine Liste aller Mock-Aufrufe zusammen mit ihren Argumenten aus. Damit können Sie sich vergewissern, dass der Code korrekt mit den Abhängigkeiten interagiert.
Fazit: Testgetrieben denken
Testgetriebene Entwicklung ist nicht einfach nur eine Technik. TDD bedeutet, anders zu denken, die Softwareentwicklung anders anzugehen. Wenn man sich zuerst über die erwarteten Ergebnisse Gedanken macht, gewinnt man Klarheit darüber, was man programmiert und warum.
Der Zeitaufwand am Anfang, wenn man die Tests schreibt, zahlt sich später aus: Das Debugging geht schneller, das Refactoring wird einfacher, und man versteht das ganze System besser. In Kombination mit strategischem Mocking und gut abgesteckten Integrationstests führt TDD dazu, dass die Entwicklung zuverlässiger wird – und oft auch mehr Spaß macht!
Wenn Sie also das nächste Mal einfach drauflos coden wollen, halten Sie kurz inne und fragen Sie sich: „Welcher Test würde beweisen, dass meine Lösung funktioniert?“ Ihr künftiges Ich wird Ihnen danken, wenn die Testsuite einen Fehler findet, lange bevor die Software in Produktion geht.