Best Practices für Unit Testing

Jeder Bug, sein Unit-Test

Meiner Meinung nach ist das hier eine der wichtigsten Best Practices überhaupt, wenn man große Softwareframeworks oder umfangreiche Libraries langfristig warten möchte.
Trotz aller Mühe gilt: Selbst mit intensivem, automatisiertem Testen werdet ihr nie bei null Bugs landen. Ja, ihr werdet deutlich weniger Fehler haben, aber ein paar Bugs werden immer durchrutschen. Kein Mensch und kein Code sind perfekt.

Der entscheidende Punkt ist, wie man mit diesen Fehlern umgeht.
Jedes Mal, wenn ein Bug auftaucht, ist das ein Warnsignal: Irgendwann wurde einmal ein Test Case übersehen. Auch wenn der erste Impuls ist, sofort in den Code zu springen und den Bug zu fixen, lohnt es sich, kurz innezuhalten.

Fragt euch:

  • Warum hat dieser Test gefehlt?
  • Wie lässt sich die Testabdeckung erweitern, damit dieser Fehler in Zukunft automatisch erkannt wird?

💡Merksatz:

Jeder Bug bekommt seinen eigenen Unit-Test. Wenn du einen Bug entdeckst, schreibe zuerst einen Unit-Test, der fehlschlägt. Danach erst korrigiere den Bug und behalte den Unit-Test für immer, um auch bei Refactoring sicher zu sein.

Folgenden Ablauf kann ich empfehlen:

  1. Bug reproduzieren: Schreibe zuerst einen Test, der den Fehler eindeutig und isoliert sichtbar macht. Er muss reproduzierbar fehlschlagen, sonst hast du den Bug nicht wirklich erwischt.
  2. Bug fixen: Passe den Code so an, dass der Test bestanden wird.
  3. Test behalten: Der Test bleibt für immer im Code. Ab jetzt läuft er bei jedem Build und verhindert, dass dieser Fehler unbemerkt zurückkehrt.

Diese Vorgehensweise hat zwei Vorteile:

  • Fehler kehren nicht zurück, weil der Test sie sofort erkennt.
  • Du dokumentierst das Problem. Der Test selbst ist der beste Beweis, dass du es verstanden und gelöst hast.

Ich habe es schon oft erlebt: Jahre später taucht ein Problem erneut auf, weil jemand den Code ändert und nicht wusste, dass er damit einen alten Bug reaktiviert. Mit einem Unit-Test wäre das nie passiert.

Kurz gesagt: Ein gefixter Bug ohne Test ist kein Fix, sondern eine Pause.

Gerade wenn du viel mit Legacy-Code oder in großen Frameworks arbeitest, kann dir diese Vorgehensweise die Arbeit erheblich erleichtern.

Teste Verhalten, nicht Implementierung

Ein häufiger Fehler: Man verändert den Code nur, um ihn besser testen zu können – etwa durch zusätzliche Rückgabewerte oder Hilfsvariablen, die für die eigentliche Funktion keinen Mehrwert haben.

Das ist ein Anti-Pattern.

Ein Test soll prüfen, ob eine Funktion korrekt reagiert und nicht wie sie intern aufgebaut ist.
Und schon gar nicht sollte der Test den Code zwingen, ihm etwas „zurückzugeben“, was er eigentlich gar nicht braucht.


Anti-Pattern: Code wird verbogen – nur für den Test

def darf_stornieren_bad_practice(status):
    if status == "geliefert":
        return False, "bereits geliefert"  # zweiter Rückgabewert nur für Tests!
    elif status == "offen":
        return True, None
    elif status == "bezahlt":
        return True, None
    return False, "unbekannter Status"

➡️ Die Funktion gibt hier zusätzliche Informationen zurück – nicht, weil das System sie braucht, sondern nur, weil der Test sie gern hätte.

So sehen die Tests dann aus (für die maximale Lesbarkeit auch für Einsteiger wurde bewusst auf die Verwendung von @pytest.mark.parametrize verzichtet):

def test_stornierungserlaubnis_mit_begründung():
    erlaubt, grund = darf_stornieren_bad_practice("geliefert")
    assert erlaubt is False
    assert grund == "bereits geliefert"
    erlaubt, grund = darf_stornieren_bad_practice("offen")
    assert erlaubt is True
    assert grund == None
    erlaubt, grund = darf_stornieren_bad_practice("bezahlt")
    assert erlaubt is True
    assert grund == None

Was daran falsch ist:

  • Der Test prüft interne Begründungen. Geprüft werden sollte immer das Verhalten nach außen.
  • Die Funktion wird unnötig komplex, obwohl ein einfacher True/False Return-Wert reichen würde.
  • Der zweite Rückgabewert bringt im echten Code keinen Nutzen, sondern wurde nur für den Test eingeführt.

Besser: Funktion macht genau das, was sie soll

def darf_stornieren(status):
    return status in ["offen", "bezahlt"]

Und der Test dazu?

def test_stornierungserlaubnis():
    assert darf_stornieren("geliefert") is False
    assert darf_stornieren("offen") is True
    assert darf_stornieren("bezahlt") is True

➡️ Klar, einfach, korrekt.
Der Test fragt: „Darf man stornieren?“ – nicht: „Warum genau nicht?“


💡Merksatz:

Wenn du den Code umbauen musst, nur damit der Test ihn „versteht“, testest du nicht das Verhalten – du missbrauchst die Implementierung.

Tests sollen dem Code nicht diktieren, was er zurückgeben muss, sondern sollen nur prüfen, ob das Verhalten stimmt. Man sollte das aber nicht damit verwechseln, dass sich dein Code kaum verändert, sobald du mit TDD startest. Im Gegenteil – er wird sich mit der Zeit erheblich verändern. Er wird modularer, leserlicher und deutlich besser wartbar. Künstliche Hilfsvariablen nur zum Testen gehören aber nicht dazu.

Halte Tests klein, unabhängig und eindeutig

Ein guter Unit Test hat drei Eigenschaften:

  • Klein: Er testet genau eine Sache.
  • Unabhängig: Er hängt nicht vom Zustand anderer Tests ab.
  • Eindeutig: Wenn er fehlschlägt, weißt du sofort, warum.

💡Merksatz:

Ein guter Unit Test ist kompakt, steht auf eigenen Beinen und verrät dir sofort, ob alles passt.

Verwende aussagekräftige Testnamen

Ein Testname ist oft das Erste, was du oder ein Teammitglied sehen, wenn ein Test fehlschlägt.
Er ist deine erste – und manchmal einzige – Dokumentation darüber, was dieser Test überhaupt prüft.

Namen wie TestCase17 oder FB_Test_03 sind nichts anderes als Rätsel. Sie zwingen dich, den Code zu öffnen, nur um herauszufinden, worum es überhaupt geht. Das bindet unnötig Zeit und erschwert die Fehlersuche.

Besser: Der Testname sagt dir auf einen Blick:

  • Welcher Funktionsbaustein getestet wird
  • Was er tun soll
  • Unter welcher Bedingung dies gelten soll

Ein mögliches und in TwinCAT/TcUnit oft praktisches Namensschema ist:
FB_<BausteinName>_<Aktion>_When<Bedingung>

Das ist aber nur eine Variante. Je nach Team und Projekt können auch andere Muster gut funktionieren – entscheidend ist, dass der Name klar, eindeutig und selbsterklärend ist.


❌ Schlechte Namen (nichtssagend)

  • TestCase17
  • FB_Test_03
  • CheckStatus
  • Run1

✅ Gute Namen (klar und beschreibend)

  • FB_ConveyorControl_StartsMotor_WhenStartCommandIsTrue
  • FB_SafetyDoor_Locks_WhenMachineIsRunning
  • FB_TemperatureControl_ShutsDownHeating_AboveMaxTemperature
  • FB_RobotAxis_MovesToHomePosition_WhenResetCommandIssued
  • FB_EventManager_LogsError_WhenInvalidEventReceived

💡Merksatz:

Ein guter Testname ist wie eine gute Commit-Message: Man versteht sofort, worum es geht – ganz ohne weiteren Kommentar.

Besser anfangen als warten

💡Merksatz:

Lieber heute mit automatischen Unit-Tests starten und die Pipeline manuell triggern, als monatelang auf die perfekte Automatisierung zu warten.


Beitrag veröffentlicht

in

Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

de_DEDeutsch