Eine Legacy-Business-Anwendung ohne Tests sicher verändern ── Charakterisierungstests und Refactoring in der Praxis

· · Legacy-Technologie, Nutzung vorhandener Ressourcen, Refactoring, Testdesign, Charakterisierungstest, C#, .NET, Wartung, Entscheidungstabelle, Technische Beratung

„Ich weiß genau, was ich reparieren müsste. Aber der Gedanke, dass ich durch das Anfassen etwas anderes kaputtmache, hält mich davon ab, es überhaupt anzupacken“ – das hören wir ständig von Menschen, die eine Business-Anwendung ohne Tests übernommen haben.

Viele in VB6, .NET Framework oder Access geschriebene Business-Anwendungen haben überhaupt keine automatisierten Tests. Das Spezifikationsdokument, sofern es je eines gab, wurde längst nicht mehr gepflegt, sodass „der Code ist die einzige Spezifikation“ die tatsächliche Lage ist. Und trotzdem läuft der Geschäftsbetrieb weiter, und Anfragen wie die Änderung des Mehrwertsteuersatzes, die Korrektur eines Berichtslayouts oder das Hinzufügen eines neuen Geschäftspartners warten auf niemanden.

In diesem Blog haben wir in „Wie lange läuft eine VB6-Anwendung noch? ── Ein realistischer Weg zur .NET-Migration“ die Idee vorgestellt, das alte System als „lebende Spezifikation“ zu behandeln und eine Migration voranzutreiben, indem man Ausgaben dagegen abgleicht. Dieser Artikel wendet dieselbe Idee auf eine andere Situation an: nicht das Wegmigrieren, sondern das Verändern von Code, der gerade jetzt, an Ort und Stelle, läuft. Das zentrale Werkzeug dafür ist der Charakterisierungstest. Selbst bei Code ganz ohne Tests werden Refactoring und Funktionsarbeit erheblich sicherer, wenn Sie zuerst in einem Test festschreiben, welches Verhalten Sie möglicherweise gleich brechen.

1. Zunächst das Fazit

  • Reparieren Sie nicht sofort. Schreiben Sie zunächst das aktuelle Verhalten in einem Test fest. Selbst ohne Spezifikationsdokument ist die Ausgabe des aktuell laufenden Codes selbst die Spezifikation.
  • Das Werkzeug dafür ist der Charakterisierungstest. Statt „korrektem Verhalten“ zeichnet er das „aktuelle Verhalten“ auf – er speichert die Ausgabe (Berichte, CSV-Dateien, Berechnungsergebnisse und so weiter) unverändert als Erwartungswert und vergleicht sie vor und nach einer Änderung (die Golden-Master-Methode).
  • Für Strukturen, in die sich kein Test einschieben lässt (Logik direkt in einem UI-Eventhandler, direkte Verweise auf DateTime.Now oder einen Dateipfad), schaffen Sie mit der kleinstmöglichen Änderung eine Naht (Seam) – Methodenextraktion plus Schnittstelleneinschub. Ein großer Umbau ist nicht erforderlich.
  • Mischen Sie Refactoring und Funktionsarbeit nicht im selben Commit. Das Bestehenskriterium für Refactoring ist „null Diff“, für Funktionsarbeit „nur der beabsichtigte Diff“. Mischen Sie beides, verlieren Sie die Fähigkeit zu erkennen, was ein Diff überhaupt bedeutet.
  • Wie viel Testinfrastruktur aufgebaut wird, entscheidet sich anhand von Umfang der Änderung mal verbleibender Lebensdauer des Systems mal Auswirkung eines Fehlschlags. Überall Unit-Tests einzubauen ist nicht immer die richtige Antwort – manchmal ist „nur Charakterisierungstests“ oder „nicht anfassen“ die richtige Entscheidung.
  • Sie können auch ohne CI beginnen. Ein einziges Testprojekt und ein Ordner mit Erwartungswertdateien reichen aus, um Ihre Sicherheit grundlegend zu verändern, selbst wenn Sie sie nur lokal ausführen.

2. Warum „geht Legacy-Code beim Anfassen kaputt“?

Legacy-Code zu verändern ist nicht beängstigend, weil der Code alt ist, sondern weil es keine Möglichkeit gibt zu bestätigen, ob das Ergebnis einer Änderung korrekt ist.

Michael Feathers definierte in seinem Buch „Working Effectively with Legacy Code“ Legacy-Code nicht als „einfach alten Code“, sondern als „Code ohne Tests“.1 Seine Begründung: Ohne Tests gibt es keinen schnellen Weg zu bestätigen, ob der Code bei jeder Änderung besser oder schlechter geworden ist. Nach dieser Definition ist selbst gestern geschriebener Code Legacy-Code, wenn er keine Tests hat.

Bei Code ohne Tests setzt sich folgender Teufelskreis in Gang:

  1. Ohne Tests kennen Sie das Ausmaß der Auswirkungen einer Änderung nicht, was beängstigend ist
  2. Weil es beängstigend ist, vermeiden Sie es, die bestehende Struktur anzufassen, und begnügen sich mit möglichst wenig Copy-Paste plus einer zusätzlichen bedingten Verzweigung
  3. Diese behelfsmäßigen Korrekturen häufen sich, und der Code wird noch schwerer lesbar und noch anfälliger
  4. Anfälligkeit macht es noch beängstigender (zurück zu 1)

Der Ausweg aus diesem Kreislauf ist nicht „den Mut für ein großes Refactoring aufbringen“. Die Reihenfolge ist tatsächlich umgekehrt: Zuerst ein Sicherheitsnetz (Tests) aufspannen, die Ursache der Angst beseitigen und erst danach reparieren. Doch dabei gibt es ein Henne-Ei-Problem. Um einen Test zu schreiben, braucht man eine testbare Struktur. Um die Struktur testbar zu machen, muss man aber den Code ändern (refactoren). Das bedeutet, Code ohne Tests zu ändern – ohne Tests.

Um diesen Widerspruch aufzulösen, geht die Veränderung von Legacy-Code in folgender Reihenfolge vor:1

  1. Beschränkt auf den Bereich rund um die Änderung, das aktuelle Verhalten von außen festschreiben (ein Charakterisierungstest)
  2. Innerhalb dieses Sicherheitsnetzes die kleinstmögliche Änderung mit extrem geringem Risiko, etwas kaputtzumachen (etwa eine Methodenextraktion) vornehmen und so eine Stelle schaffen, an der sich ein Test einschieben lässt
  3. Sobald die Struktur feingranulare Tests zulässt, mit der eigentlich gewünschten Änderung (Refactoring oder Funktionsarbeit) beginnen

Die folgenden Kapitel gehen die Schritte 1 und 2 konkret durch.

3. Charakterisierungstests ── „aktuelles Verhalten“ aufzeichnen

3.1 Der Unterschied zu einem normalen Test

Ein normaler Test verifiziert korrektes Verhalten – „so soll es laut Spezifikation funktionieren“. Ein Charakterisierungstest ist anders. Er zeichnet auf, wie sich der Code gerade jetzt tatsächlich verhält, und stellt die Frage, ob das korrekt ist, bewusst zurück.

Angenommen, das Spezifikationsdokument sagt nicht, ob bei der Rundung von Bruchteilen auf- oder abgerundet oder abgeschnitten werden soll. Läuft der aktuelle Code mit Abschneiden, und läuft der Geschäftsbetrieb seit zehn Jahren so, dann ist „es wird abgeschnitten“ zumindest die faktische Spezifikation. Ein Charakterisierungstest schreibt genau das fest, in der Form „die aktuelle Ausgabe ist so-und-so“. Selbst wenn sich später herausstellt, dass es ein Bug ist, schreiben Sie es zunächst fest. Das Ändern des Verhaltens (das Beheben des Bugs) erfolgt separat, als bewusste Änderung, erst nachdem das Sicherheitsnetz steht.

3.2 Die Golden-Master-Methode Schritt für Schritt

Für Legacy-Code, dessen Ausgabe auf grober Granularität arbeitet, ist die Golden-Master-Methode die kosteneffizienteste Form des Charakterisierungstests. Das Vorgehen ist unkompliziert:

  1. Die Ausgabe identifizieren, die die betreffende Funktion erzeugt (Berichtstext, eine CSV-Datei, eine Liste von Berechnungsergebnissen und so weiter)
  2. Repräsentative Eingabedaten vorbereiten, den aktuellen Code ausführen und die Ausgabe erfassen
  3. Diese Ausgabe unverändert als Erwartungswertdatei (den Golden Master) speichern und ins Repository einchecken
  4. Ab sofort bei jeder Codeänderung den Test ausführen und bestätigen, dass der Diff zwischen Ausgabe und Erwartungswertdatei null ist

Eine C#-Implementierung benötigt keine spezielle Bibliothek – etwas so Einfaches wie das Folgende genügt:

[Fact]
public void MonatlicheAbrechnungsübersicht_GoldenMaster()
{
    // 1. Eine repräsentative Eingabe einlesen (z. B. aus der Produktion entnommene, maskierte Daten)
    var input = File.ReadAllLines(TestDataPath("billing-input-202606.csv"));

    // 2. Die bestehende Logik unverändert aufrufen und die Ausgabezeichenkette erfassen
    string actual = BillingReport.Generate(input);

    // 3. Eine fehlende Erwartungswertdatei bedeutet entweder "die Testumgebung ist kaputt"
    //    oder "das ist der erste Lauf". So oder so nicht stillschweigend durchlassen,
    //    sondern nur aufzeichnen und unbedingt fehlschlagen
    string expectedPath = TestDataPath("billing-expected-202606.txt");
    if (!File.Exists(expectedPath))
    {
        File.WriteAllText(expectedPath + ".candidate", actual);
        Assert.Fail("Erwartungswertdatei fehlt. Prüfen Sie den Inhalt der .candidate-Datei " +
                    "und committen Sie ihn als Erwartungswert, falls er korrekt aussieht.");
    }

    // 4. Eine exakte Übereinstimmung mit dem gespeicherten Verhalten verifizieren
    string expected = File.ReadAllText(expectedPath);
    Assert.Equal(expected, actual);
}

Vermeiden Sie eine Implementierung, die bei fehlender Erwartungswertdatei die aktuelle Ausgabe an Ort und Stelle als Erwartungswert speichert und den Test durchlaufen lässt. Wurde die Erwartungswertdatei nie eingecheckt oder am falschen Ort abgelegt, würde CI dann grün durchlaufen, ohne je eine Regression zu erkennen. Lassen Sie die erste Aufzeichnung wie oben eine Kandidatendatei (.candidate) ausgeben und explizit fehlschlagen, sodass der Ablauf strikt einseitig ist: Eine Person prüft sie, und erst dann wird sie als Erwartungswert eingecheckt.

Da die reine Fehlermeldung von Assert.Equal schwer auszuwerten ist, sobald ein Diff auftaucht, lohnt es sich in der Praxis, bei einem Fehlschlag zusätzlich die tatsächliche Ausgabe in eine separate Datei zu schreiben, etwa billing-actual-202606.txt, damit Sie sie mit einem Diff-Tool wie WinMerge gegen den Erwartungswert vergleichen können.

3.3 Eingaben auswählen und die Ausgabe normalisieren

Wählen Sie Eingaben nach dem Muster „repräsentativ plus Grenzfälle“. Nehmen Sie ein oder zwei normale Fälle und ergänzen Sie Eingaben, die alle Verzweigungen durchlaufen, die Sie beim Lesen des Codes gefunden haben – Monatsabschluss, null Datensätze, negative Werte, Sonderbehandlung für einen bestimmten Geschäftspartner und so weiter. Können Sie maskierte Produktionsdaten verwenden, durchlaufen diese die realen Verzweigungen am zuverlässigsten.

Normalisieren Sie nichtdeterministische Werte in der Ausgabe vor dem Vergleich. Ein Druckzeitstempel, eine Verarbeitungsdauer, eine GUID, eine automatisch vergebene Nummer und Ähnliches ändern sich bei jedem Lauf und erzeugen so unverändert bei jedem Mal einen Diff. Fügen Sie nach dem Erzeugen der Ausgabe einen Vorverarbeitungsschritt ein – etwa eine Ersetzung per regulärem Ausdruck, die Gedruckt: 2026/07/17 16:00 in Gedruckt: <DATUM> umwandelt –, bevor Sie vergleichen.

Hier eine grobe Orientierung, welche Art von Ausgabe sich gut für einen Golden Master eignet:

Art der Ausgabe Eignung Anmerkung
CSV / Dateien mit fester Feldlänge Ausgezeichnet Lässt sich unverändert speichern und vergleichen. Das erste Ziel, das Sie angehen sollten
Berichte (Text oder die Quelldaten hinter einer Druckvorschau) Ausgezeichnet Die Zeichenkette kurz vor der PDF-Erzeugung erfassen; direkten Vergleich von PDF-Binärdaten vermeiden
Eine Liste von Berechnungsergebnissen (Beträge, Lagerbestände usw.) Ausgezeichnet Es ist unproblematisch, eine testexklusive Methode hinzuzufügen, die die Ergebnisse als CSV ausgibt
In die Datenbank geschriebener Inhalt Gut Den Tabelleninhalt nach dem Schreiben per SELECT abrufen, als CSV ausgeben und vergleichen
Die Bildschirmanzeige selbst Eingeschränkt Funktioniert, wenn sie sich auf eine Zeichenkette reduzieren lässt. Die Automatisierung von Bildschirmbedienungen erfordert ein anderes Werkzeugset, behandelt in „Automatisierte UI-Tests für Windows-Desktop-Anwendungen
Übertragungen an ein externes System Eingeschränkt Erfordert eine Naht (nächstes Kapitel), um die Daten unmittelbar vor dem Versand zu erfassen

4. Die „Naht (Seam)“ bauen, über die sich ein Test einschieben lässt

Der Versuch, einen Golden Master zu schreiben, stößt in vielem Legacy-Code auf eine Mauer: Die Logik steht direkt in einem UI-Eventhandler, und Sie können sie nicht ausführen, ohne den Bildschirm zu starten. Was Sie hier brauchen, ist eine Stelle, an der Testcode das Verhalten ersetzen und beobachten kann – das, was Feathers eine Naht (Seam) nennt.1

4.1 Logik per Methodenextraktion von der UI lösen

Der typische „Vorher“-Zustand sieht so aus: Berechnung, Datenbankzugriff, Zeitabhängigkeit und Bildschirmaktualisierung leben alle zusammen in einem einzigen Eventhandler.

// Vorher: alles direkt im Eventhandler
private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();                          // Direkter DB-Zugriff
    var now = DateTime.Now;                               // Abhängig von der aktuellen Zeit
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)              // Nur den laufenden Monat aggregieren
        {
            total += Math.Floor(row.Amount * 1.1m);        // Eine Geschäftsregel zur Rundung
        }
    }
    lblTotal.Text = total.ToString("N0");                  // Direkt auf dem Bildschirm angezeigt
}

So, wie es ist, erfordert das Testen der Aggregationslogik für den laufenden Monat den Bildschirm, die Datenbank und „das heutige Datum“. Der Standardweg, dies mit minimaler Änderung testbar zu machen, besteht darin, nur die Berechnung in eine Methode zu extrahieren und die externen Abhängigkeiten (die DB-Ergebnisse und die aktuelle Zeit) in Parameter zu verwandeln. Auch das Extract-Method-Refactoring von Visual Studio (Strg+R, M) reduziert dabei manuelle Umschreibfehler.2

// Nachher: nur die Berechnung wird extrahiert und nimmt "die DB-Ergebnisse"
// sowie "die aktuelle Zeit" als Parameter entgegen
internal static decimal CalcMonthlyTotal(IEnumerable<SalesRow> rows, DateTime now)
{
    decimal total = 0;
    foreach (var row in rows)
    {
        if (row.SalesDate.Year == now.Year &&
            row.SalesDate.Month == now.Month)
        {
            total += Math.Floor(row.Amount * 1.1m);
        }
    }
    return total;
}

private void btnCalc_Click(object sender, EventArgs e)
{
    var rows = LoadRowsFromDb();
    lblTotal.Text = CalcMonthlyTotal(rows, DateTime.Now).ToString("N0");
}

Die Seite des Eventhandlers besteht jetzt aus drei Zeilen – Laden, Berechnen, Anzeigen –, und die extrahierte Methode lässt sich mit beliebigen Zeilendaten und einem beliebigen Datum testen. Auch zeitbezogene Grenzfälle wie Monatsende, Monatsanfang oder ein Schaltjahr lassen sich einfach reproduzieren, indem Sie ein Datum wie new DateTime(2028, 2, 29) übergeben.

4.2 Abhängigkeiten per Schnittstelleneinschub austauschbar machen

Bei Abhängigkeiten, die sich nicht allein durch Parametrisierung lösen lassen (DateTime.Now wird überall referenziert, ein Dateipfad ist hart codiert und so weiter), kapseln Sie die Abhängigkeit hinter einer Schnittstelle und schieben diese ein. Auch die Best Practices von Microsoft Learn für .NET-Unit-Tests nennen eine direkte Abhängigkeit von DateTime.Now als klassisches Beispiel für etwas, das sich aus einem Test heraus nicht kontrollieren lässt, und beschreiben das Einkapseln in einer Schnittstelle als Mittel, um eine Naht (Seam) einzuführen.3

public interface IClock
{
    DateTime Now { get; }
}

public sealed class SystemClock : IClock
{
    public DateTime Now => DateTime.Now;
}

// Auf der Testseite wird eine Implementierung eingeschoben, die eine feste Zeit zurückgibt
public sealed class FixedClock : IClock
{
    private readonly DateTime _fixed;
    public FixedClock(DateTime value) => _fixed = value;
    public DateTime Now => _fixed;
}

Fügt man IClock dem Konstruktor einer bestehenden Klasse hinzu, müssen zwangsläufig alle Aufrufstellen angepasst werden; deshalb ist es in der Übergangsphase realistisch, zusätzlich einen parameterlosen Konstruktor mit SystemClock als Standardwert bereitzustellen und die Aufrufstellen nach und nach anzupassen. Fest codierte Dateipfade oder DB-Verbindungszeichenfolgen lassen sich auf dieselbe Art hinter einer kleinen Schnittstelle kapseln, die nur die benötigten „Lese-/Schreiboperationen“ freigibt.

Zu beachten ist, dass Visual Studio auch ein integriertes Refactoring zum Extrahieren einer Schnittstelle aus einer bestehenden Klasse besitzt (Extract Interface), mit dem sich diese Art von Änderung mechanisch durchführen lässt.2

Beim Schaffen einer Naht gilt ein Grundsatz: Die Änderung, die die Naht schafft, darf das Verhalten um keinen Millimeter verändern. Sowohl Methodenextraktion als auch Schnittstelleneinschub sind mechanische, stark verhaltensbewahrende Operationen, die Compiler und IDE direkt unterstützen können. In diesem Stadium ist die Versuchung groß, „die Logik gleich mit zu korrigieren“ – widerstehen Sie dem; das ist Arbeit für die Zeit nach dem Aufspannen des Sicherheitsnetzes.

5. Eine Entscheidungstabelle dafür, wie weit man gehen sollte

Sowohl Charakterisierungstests als auch der Bau von Nähten kosten Aufwand. Alle Legacy-Code-Bereiche auf denselben Testniveau zu bringen, ist für ein kleines oder mittleres Team nicht realistisch, und es besteht auch keine Notwendigkeit dafür. Es gibt drei Entscheidungsachsen:

  • Umfang der Änderung: eine Fehlerbehebung von wenigen Zeilen, eine Funktionserweiterung oder etwas, das eine strukturelle Änderung mit sich bringt
  • Verbleibende Lebensdauer des Systems: für Migration oder Abschaltung in ein bis zwei Jahren vorgesehen, oder für fünf Jahre und mehr in Betrieb erwartet
  • Auswirkung bei einem Fehlschlag: kosmetischer Schaden am Berichtslayout oder ein falscher Rechnungsbetrag oder Lagerbestand
Umfang der Änderung Verbleibende Lebensdauer Auswirkung bei Fehlschlag Empfohlenes Niveau
Gering (wenige Zeilen, Konfigurationswertänderung) Kurz (bis ~2 Jahre) Klein (kosmetische Anzeigeprobleme) Nur Charakterisierungstests. Die betroffene Ausgabe festschreiben, ändern, null Diff bestätigen, fertig
Gering bis mittel Kurz Groß (betrifft Geld oder Lagerbestand) Nur Charakterisierungstests, aber umfangreicher. Die Eingabemuster einschließlich Grenzfällen erweitern
Mittel (Funktionserweiterung, Logikänderung) Lang (5+ Jahre) Klein bis mittel Charakterisierungstests plus Unit-Tests, beschränkt auf den Bereich rund um die Änderung (eine Naht bauen)
Mittel bis groß Lang Groß Charakterisierungstests plus Unit-Test-Infrastruktur, plus Aufteilung des Releases in kleinere Einheiten
Groß (eine strukturelle Überarbeitung ist nötig) Kurz Nicht anfassen. Statt zu ändern, betrieblich umgehen und den Aufwand in Migration/Ersatz investieren
─ (es gibt gar keine Änderungsanfrage) Nicht anfassen. Bereits funktionierenden Code nicht vorsorglich refactoren

Die beiden unteren Zeilen mit „nicht anfassen“ sind keine passive Standardwahl – sie sind eine aktive Entscheidung. In die interne Qualität eines Systems mit kurzer Restlaufzeit zu investieren, zahlt sich nicht aus. Diesen Aufwand sollten Sie stattdessen in die Migrationsentscheidung stecken, die in „Eine Entscheidungstabelle für die Verlängerung oder Migration einer VB6-/Access-Business-Anwendung“ dargelegt wird, sowie in den Entwurf des Zielsystems.

Gehen Sie zudem bis zum Aufbau von Unit-Test-Infrastruktur, müssen Sie eine Grenze ziehen zwischen dem, was in einen Unit-Test gehört, und dem, was einem Integrationstest (der auf eine echte Datenbank oder echte Dateien zugreift) vorbehalten bleiben sollte. Diese Grenze arbeiten wir als Entscheidungstabelle in „Wo die Grenze zwischen Unit-Tests und Integrationstests ziehen“ aus – es lohnt sich, diesen Artikel begleitend zu lesen. Angesichts der Tatsache, dass Unit-Tests schnell, isoliert und wiederholbar sein sollten,3 ist es ratsam, Charakterisierungstests, die eine Datenbank oder das Dateisystem berühren, in einem separaten Projekt oder einer separaten Ausführungseinheit von den Unit-Tests zu trennen.

6. Betriebsregeln ── damit das Sicherheitsnetz nicht reißt

Ein Charakterisierungstest verliert seinen Sinn leicht, wenn man die Betriebsregeln danach falsch handhabt. Hier die drei wichtigsten.

6.1 Refactoring und Funktionsarbeit nicht im selben Commit mischen

Refactoring ist eine Änderung, die Code verständlicher und wartbarer macht, ohne sein Verhalten zu ändern.4 Das Bestehenskriterium lautet also null Diff gegenüber dem Golden Master. Funktionserweiterung oder Fehlerbehebung dagegen haben als Bestehenskriterium, dass nur der beabsichtigte Diff auftaucht. Mischt man beides in einem Commit, lässt sich, sobald ein Diff auftaucht, nicht mehr feststellen, ob es „die beabsichtigte Änderung“ war oder „etwas kaputtgegangen ist“.

Art der Änderung Umgang mit dem Golden Master Bestehenskriterium
Refactoring (strukturelle Änderung) Nicht aktualisieren Null Diff
Fehlerbehebung / Funktionserweiterung (Verhaltensänderung) Nach einer Diff-Review aktualisieren Nur der beabsichtigte Diff
Nahtbau (Methodenextraktion, Schnittstelleneinschub) Nicht aktualisieren Null Diff
Änderung der Normalisierungsregel für Erwartungswerte Neu erzeugen Änderungsgrund in der Commit-Nachricht angeben

Dasselbe gilt auf Release-Ebene. Ein „Release, das nur Refactoring enthält“ soll das Verhalten unverändert lassen, sodass Sie bei einem Vorfall sofort das Refactoring in Verdacht haben können. Mischt man beides, funktioniert diese Eingrenzung nicht mehr.

6.2 Erwartungswerte in der Reihenfolge „Diff prüfen → überschreiben“ aktualisieren

Ändern Sie das Verhalten bewusst, aktualisieren Sie auch den Golden Master. Legen Sie das Vorgehen fest:

  1. Die Ausgabe nach der Änderung erzeugen und den Diff gegenüber dem aktuellen Erwartungswert visuell prüfen
  2. Bestätigen, dass der Diff nur die beabsichtigte Änderung enthält (bei auch nur einer unbeabsichtigt geänderten Zeile nachforschen)
  3. Die Erwartungswertdatei mit der neuen Ausgabe überschreiben und im selben Commit wie den Code in die Historie aufnehmen

Gefährlich ist das Betriebsmuster „der Test ist rot geworden, also überschreiben wir den Erwartungswert, damit er grün wird“. Tut man das, wird eine Regression unverändert als „korrekt“ aufgezeichnet, und das Sicherheitsnetz hört auf, ein Sicherheitsnetz zu sein.

6.3 Auch ohne CI eine kleinstmögliche lokal lauffähige Ausstattung aufbauen

Selbst in einem Team ohne CI-Server können Sie mit folgendem Minimalaufbau noch heute anfangen:

  • Der Solution ein einziges Testprojekt hinzufügen (MSTest, NUnit und xUnit funktionieren alle, selbst unter .NET Framework)
  • Erwartungswertdateien und Eingabedaten in einem TestData-Ordner ablegen und zusammen mit dem Code versionieren
  • Zur Teamregel machen, dotnet test (oder den Test Explorer von Visual Studio) vor jedem Commit manuell auszuführen
  • Der Release-Anleitung eine Zeile hinzufügen – „Tests ausführen und null Diff bestätigen“ –, damit niemand vergisst, das Ergebnis zu prüfen

Je besser Ihr Logging ist, desto schneller lässt sich beim Abgleich von Testergebnissen und der eigenen Ausgabe der Anwendung untersuchen. Was protokolliert werden sollte, behandeln wir in „Mindestanforderungen an einen selbst gebauten Logger und eine Checkliste für Integrationstests“.

7. Zusammenfassung

  • Legacy-Code ist „Code ohne Tests“,1 und der eigentliche Grund, warum er beim Anfassen kaputtgeht, ist, dass es keine Möglichkeit gibt, das Ergebnis einer Änderung zu bestätigen. Schreiben Sie das aktuelle Verhalten in einem Test fest, bevor Sie etwas reparieren.
  • Ein Charakterisierungstest zeichnet „aktuelles Verhalten“ auf, nicht „korrektes Verhalten“. Mit der Golden-Master-Methode – Berichte, CSV-Dateien oder Berechnungsergebnisse unverändert in Erwartungswertdateien speichern und vergleichen – können Sie mit reinem C#-Code beginnen.
  • Für Strukturen, in die sich kein Test einschieben lässt, bauen Sie mit Methodenextraktion und Schnittstelleneinschub eine Naht. Eine Abhängigkeit wie DateTime.Now zu kapseln, ist eine Standardtechnik, die auch die eigene Unit-Test-Anleitung von Microsoft beschreibt.32
  • Wie viel Infrastruktur aufgebaut wird, entscheidet sich anhand von Umfang der Änderung mal verbleibender Lebensdauer mal Auswirkung bei einem Fehlschlag. „Nur Charakterisierungstests“ oder „nicht anfassen“ sind beide vollkommen legitime Entscheidungen.
  • Im Betrieb sollten Sie Refactoring (Bestehenskriterium ist null Diff) und Funktionsarbeit (Bestehenskriterium ist nur der beabsichtigte Diff) nicht mischen,4 und eine Aktualisierung des Erwartungswerts immer durch eine Diff-Review laufen lassen. Selbst ohne CI verändert allein die Teamzusage, Tests lokal auszuführen, die Sicherheit erheblich.

Verwandte Artikel

Verwandte Beratungsbereiche

KomuraSoft LLC (合同会社小村ソフト) übernimmt die Einführung von Charakterisierungstests in bestehende Business-Anwendungen ohne Tests, das schrittweise Refactoring hin zu einer testbaren Struktur sowie die Unterstützung bei der Entscheidung, ob in Überarbeitung oder Migration investiert werden sollte.

  1. Michael C. Feathers, “Working Effectively with Legacy Code” (Prentice Hall, 2004). Zur Definition von Legacy-Code als „Code ohne Tests“, zum Vorgehen, mit einem Charakterisierungstest das aktuelle Verhalten aufzuzeichnen, bevor eine Änderung beginnt, sowie zum Konzept der Naht (Seam), über die sich ein Test einschieben lässt.  2 3 4

  2. Microsoft Learn, Extract and inline refactorings (Visual Studio). Zu den Bedienschritten der Visual-Studio-Refactorings Extract Method (Strg+R, M) und Extract Interface für C#/Visual Basic.  2 3

  3. Microsoft Learn, Unit testing best practices for .NET. Zu den Eigenschaften eines guten Unit-Tests (fast / isolated / repeatable / self-checking / timely), zur Technik, eine unkontrollierbare Abhängigkeit wie DateTime.Now in einer Schnittstelle zu kapseln, um eine Naht einzuführen, sowie dazu, Infrastrukturabhängigkeiten aus Unit-Tests herauszuhalten und stattdessen in Integrationstests zu verlagern.  2 3

  4. Microsoft Learn, Refactor code (Visual Studio). Zur Definition von Refactoring als dem Prozess, Code zu ändern, um ihn leichter zu warten, zu verstehen und zu erweitern, ohne sein Verhalten zu ändern.  2

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Was ist ein Charakterisierungstest?
Es ist ein Test, der das „aktuelle Verhalten“ so, wie es ist, festhält, statt das „korrekte Verhalten“ zu prüfen. Bei Legacy-Code, bei dem das Spezifikationsdokument längst verschwunden ist, gibt es oft gar keine Möglichkeit zu prüfen, was überhaupt korrekt wäre. Deshalb speichern Sie zunächst die Ausgabe des Codes, wie er aktuell läuft (ein Bericht, eine CSV-Datei, ein Berechnungsergebnis und so weiter), als Erwartungswert und bestätigen mechanisch, dass sich die Ausgabe vor und nach einer Änderung nicht verändert hat. Das Grundmuster besteht darin, dieses Sicherheitsnetz aus festgeschriebenem Verhalten zuerst aufzuspannen und erst danach mit Refactoring oder dem Hinzufügen von Funktionen weiterzumachen.
Wo fange ich bei Legacy-Code an, der überhaupt keine Tests hat?
Der realistische Ansatz besteht darin, Charakterisierungstests eng auf den Bereich zu beschränken, den Sie gerade ändern wollen. Das gesamte System mit Tests abzudecken, rechnet sich in den allermeisten Fällen aufwandsmäßig nicht, und es ist auch nicht nötig. Identifizieren Sie zunächst die Ausgabe, die die betroffene Funktion erzeugt (ein Bericht, eine CSV-Datei, was in die Datenbank geschrieben wird und so weiter), speichern Sie die Ausgabe für repräsentative Eingaben in einer Datei und schreiben Sie sie fest. Führen Sie innerhalb dieses Sicherheitsnetzes kleine, verhaltensbewahrende Refactorings wie eine Methodenextraktion durch, um die Logik in eine testbare Form zu bringen, und beginnen Sie erst dann mit der eigentlich gewünschten Änderung.
Warum sollten Refactoring und Funktionsarbeit nicht im selben Commit gemischt werden?
Weil man, sobald ein Diff in der Ausgabe auftaucht, nicht mehr feststellen kann, was ihn verursacht hat. Refactoring wird dadurch verifiziert, dass man bestätigt, „das Verhalten hat sich nicht geändert“, während Funktionsarbeit dadurch verifiziert wird, dass man bestätigt, „nur die beabsichtigte Stelle hat sich verändert“ – die Bestehenskriterien sind gegensätzlich. Mischt man beides, lässt sich nicht mehr erkennen, ob eine Abweichung vom Golden Master „die beabsichtigte Änderung“ ist oder „etwas kaputtgegangen ist“. Sicherer ist es, sie getrennt zu halten: null Diff für einen Refactoring-Commit, nur der beabsichtigte Diff für einen Funktions-Commit.
Wann sollte ich den Golden Master (die Erwartungswertdatei) aktualisieren?
Nur in dem Moment, in dem Sie das Verhalten bewusst ändern – also bei einem Commit zur Funktionserweiterung oder Fehlerbehebung. Überprüfen Sie beim Aktualisieren visuell den Diff zwischen alter und neuer Ausgabe, bestätigen Sie, dass er nur die beabsichtigte Änderung enthält, und ersetzen Sie erst dann den Erwartungswert durch die neue Ausgabe. Überschreiben Sie den Erwartungswert mechanisch nur deshalb, weil der Test rot geworden ist, nehmen Sie stillschweigend eine Regression (eine unbeabsichtigte Verhaltensänderung) als „korrekt“ auf – womit der gesamte Sinn des Sicherheitsnetzes verloren geht.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog