Björn Geisemeyer
Björn Geisemeyer
,

Legacy Code testen mit Approval Tests

Die Code-Weiten des Legacy-Raums erkunden – Wo noch nie ein Test zuvor gewesen ist

In den Galaxien der Softwareentwicklung ist die Sicherstellung der Qualität von größter Bedeutung und gleichzeitig so oft vernachlässigt. Die Arbeit in ungetestetem und unbekanntem Code ist so üblich wie allgemein unbeliebt. Wer freut sich schon auf Legacy Code?! Jahrelang hat es mir geschaudert vor Bugfixes oder neuen Features, die in einen alten, vermurksten, unverständlichen Codehaufen gebastelt werden mussten. Refactorings gehörten zu meinem Alltag, oft genug ohne die so unbedingt notwendige Absicherung. Es ist unerlässlich, den Code vor dem Refactoring unter Test zu stellen. Das vorhandene Verhalten darf nicht verändert werden. Gleichzeitig stellt das Schreiben von Unit-Tests für Legacy Code eine Herausforderung dar: Wie müssen meine Assertions aussehen? Wie erfasse ich alle Use Cases, Fail Cases und Edge Cases? Wie finde ich heraus was der Code eigentlich tut? Was für ein Setup brauche ich für meine Tests? All das muss erst in Erfahrung gebracht werden. Oder nicht? Glücklicherweise gibt es eine Alternative: Approval Tests.

Im Folgenden beschreibe ich zunächst die Vorgehensweise. Weiter unten folgen dann Beispiele.

Wie funktionieren Approval Tests?

Approval Tests werden wie Unit-Tests im Code implementiert. Sie überprüfen das Ergebnis eines Testdurchlaufs anhand des genehmigten Ausgangszustands (approved output) eines vorherigen Durchlaufs desselben Tests. Dieser genehmigte Ausgangszustand, von einigen als Gold Master bezeichnet, dient als Referenz für kommende Testdurchläufe. Ein Test schlägt fehl, wenn das aktuelle Ergebnis nicht identisch ist mit dem Genehmigten. In Folge eines Fehlschlags öffnet sich ein Diff-Tool, welches uns die beiden Ergebnisse gegenüberstellt. Wir können nun die Entscheidung treffen, ob das aktuelle Ergebnis unseren Erwartungen entspricht oder nicht. Wenn das aktuelle Ergebnis erwünscht ist, wird es genehmigt und damit zur neuen Referenz.

Natürlich gibt es Frameworks, die diese Testweise unterstützen.ApprovalTests.com beispielsweise bietet für eine Vielzahl von Sprachen Pakete an. Für unser C# Beispiel benutzen wir das Framework Verify.

Wie unterscheiden sich Approval Tests von Unit Tests?

Unit Tests definieren erwartete Ergebnisse, um das Verhalten von Code zu überprüfen. Es wird die Annahme gemacht, dass der Code das erwartete Ergebnis liefert. Der Code soll ein gewünschtes Verhalten erfüllen. Der Test schlägt so lange fehl, bis ein bestimmtes Verhalten erreicht ist. Approval Tests dagegen dokumentieren den Ist-Zustand. Der bestehende Code liefert selbst das Ergebnis, auf das anschließend geprüft wird. Dabei kann der Ist-Zustand durchaus ein anderer sein als das gewünschte Verhalten. Der Test ist so lange erfolgreich, bis sich das vorhandene Verhalten ändert.

Unit Tests überprüfen isoliert spezifisches Verhalten. Zu einem Stück Code werden Use Cases, Fail Cases und Edge Cases analysiert und für jeden Fall mindestens ein Test erzeugt. Schlägt ein Test fehl, wissen wir präzise wo und aus welchem Grund.

Approval Tests vergleichen ein Endergebnis. Es gibt einen Test, der das gesamte Verhalten abdeckt. Der erkennbare Nachteil gegenüber Unit Tests ist, dass nicht präzise erkannt wird, wo eine Änderung das Verhalten verändert hat. Genaugenommen könnten sogar mehrere Fälle verletzt worden sein. Die Quelle der Veränderung muss im Zweifelsfall ermittelt werden. Der offensichtliche Vorteil von Approval Tests ist, Veränderungen im Verhalten erfassen zu können, die selbst in einer detaillierten Unit-Test Suite übersehen wurden. Unit Tests erwarten ein Verhalten, alle Fälle müssen vorhergesehen werden. Unvorhergesehenes Verhalten wird folglich nicht abgedeckt. Approval Tests finden jede Änderung im Verhalten.

Unit Tests sind Tests, die aus der Perspektive von Entwicklern und Entwicklerinnen geschrieben werden. Sie sind feingranular und testen die einzelnen Bestandteile, die zur Implementation eines Verhaltens gehören. Approval Tests können vielseitiger eingesetzt werden. Sie können den Zweck eines Unit-Tests erfüllen, doch deutlich besser bedienen sie die Perspektive von Benutzern und Benutzerinnen. Sie können hervorragend eingesetzt werden, um Use Cases zu prüfen und nehmen damit den Platz eines Akzeptanztests ein.

Wann sollten wir Approval Tests einsetzen?

In einer uns unbekannten Codebasis helfen uns Approval Tests, das Verhalten zu erfassen. Eine Testsuite, die unbekannten Code Stück für Stück mit Approval Tests versieht, schafft eine Dokumentation des Ist-Zustands. Diese Tests ermöglichen uns ein Verständnis über die Arbeit des Codes. Michael Feathers beschreibt dieses Verfahren in seinem Buch Working effectively with legacy code (In unseren Buchempfehlungen). In einem Artikel nennt er dieses Vorgehen Characterization Testing, denn die Tests enthüllen den Charakter der Codebasis.

Komplexe Daten stellen eine Herausforderung für Unit Tests dar, weil sie oft eine Vielzahl von Assertions benötigen, um ein bestimmtes Ergebnis zu prüfen. Datenstrukturen wie JSON, XML, HTML, HTTP-Anfragen, JPG, PNG, PDF und weitere lassen sich nur unter teils hohem Aufwand auswerten oder es kann kein erwartetes Ergebnis erzeugt werden. Approval Tests können hier effektiv eingesetzt werden und deutlich Aufwand einsparen, oder bestimmte Tests überhaupt erst möglich machen.

Refactoring von Legacy Code

Eine unbedingte Regel des komplexen Refactorings ist: Unter Test stellen. Bevor wir auch nur eine Zeile im Legacy Code refaktorisieren, müssen wir sicherstellen, dass vorhandenes Verhalten sich nicht durch unsere Refactorings ändert. Unit Tests sind oft nicht, oder nur mit einem aufwändigen Setup, umsetzbar. Zudem müssen wir wissen, was genau der Code tut. Approval Tests sind hier eine hervorragende Alternative. In der Regel benötigen sie ein deutlich kleineres Setup. Und wir müssen nicht einmal wissen, was der Code verarbeitet. Wir können erst mal den Ist-Zustand festhalten und uns beim Refactoring damit befassen, was passiert.

Das erweiterte Muster: Arrange, Act, Print, Verify

Das traditionelle Testmuster „Arrange, Act, Assert“ wird bei Approval Tests angepasst. Das Assert wird durch den Schritt Verify ersetzt, der den Vergleich zwischen den beiden Ergebnissen macht. Vor dem Verify wird noch ein zusätzlicher Schritt Print ergänzt. Dieser Schritt ist von entscheidender Bedeutung, er erfüllt mehrere Zwecke.

Erstens stellen wir sicher, dass die Vergleiche zwischen dem aktuellen Zustand und dem genehmigten Ausgangszustand funktionieren. Dafür filtern wir die Ergebnismenge. Volatile Daten wie Zeitstempel oder Guids lassen Tests fehlschlagen. Diese Daten müssen also aus der zu vergleichenden Datenmenge entfernt werden.

Zweitens sollten wir dafür sorgen, dass wir für die Auswertung des Vergleichs eine möglichst lesbare Ergebnismenge bekommen. Wir möchten uns ja nicht durch den Vergleich quälen.

Drittens müssen wir Ergebnismengen häufig erst in eine textuelle Repräsentation übersetzen, damit sie mittels Diff-Tools ausgewertet werden können.

Wenn all das nicht notwendig oder sinnvoll ist, können wir den Print Schritt überspringen.

Fazit

Approval Tests sind dort gut, wo Unit Tests gern scheitern. Sei es im Refactoring Kontext, bei der Überprüfung komplexer Daten oder der Erkundung unbekannten Codes. Sie dokumentieren das aktuelle Verhalten des Codes und sind in der Lage, unvorhergesehene Änderungen aufzudecken. Gleichzeitig benötigen sie für komplexe Prüfmengen deutlich weniger Aufwand als Unit Tests. Rauft euch nicht die Haare, wenn ihr im nächsten Refactoring ein lästiges Stück Code unter Test stellen sollt. Stresst euch nicht, wenn jemand von euch verlangt, einen Test zu schreiben, um große Datenmengen zu vergleichen. Verzweifelt nicht, wenn ihr in unbekannten Code-Weiten verloren geht. Denkt an Approval Tests.

Beispiel

Neulich im Supermarkt...

Als Vorlage für diese Approval Tests Demonstration dient das https://github.com/emilybache/SupermarketReceipt-Refactoring-Kata von Emily Bache. Sie stellt ein hervorragend umfangreiches Dojo zum Thema Refactoring zur Verfügung, sowohl in der Anzahl der Katas, als auch in unterstützten Sprachen.

Meine Aufgabe ist es, den Code unter Test zu stellen, um ein Refactoring vorzubereiten. Ich verwende die C# Version. Ausgangspunkt ist eine Supermarktsoftware. Ein Blick in die Projektmappe zeigt mir die Klassenstrukturen des Produktionscodes und ein Testprojekt mit einem Unit Test [Listing 1]. Products, Discounts und Offers können angelegt werden. Ein SupermarketCatalog dient als Persistenz für Produkte und Preise. In der Klasse ShoppingCart können Produkte abgelegt werden. Die Klasse Teller (Kasse) verwaltet Angebote und erstellt ein Receipt für einen Warenkorb. Alle diese Informationen bekomme ich allein aus dem vorhandenen Unit-Test, der einen Use Case prüft. Dem Namen nach soll ein 10% Rabatt geprüft werden. Ich ignoriere den Produktionscode, der Unit Test ist Dokumentation genug.

Das Arrange führt mich lesbar durch die nötigen Vorbereitungen, im Act findet sich die zentrale Methode und im Assertprüfen sieben Assertions das Ergebniss. Kennen wir den Code nicht, bleiben dort Fragen offen. Wir wissen nicht, ob das Receipt vollständig abgeprüft ist, der Test ist bei Ausführung immerhin grün.

				
					[TestCase]
public void TenPercentDiscount()
{
    // ARRANGE
    SupermarketCatalog catalog = new FakeCatalog();
    var toothbrush = new Product("toothbrush", ProductUnit.Each);
    catalog.AddProduct(toothbrush, 0.99);
    var apples = new Product("apples", ProductUnit.Kilo);
    catalog.AddProduct(apples, 1.99);

    var cart = new ShoppingCart();
    cart.AddItemQuantity(apples, 2.5);

    var teller = new Teller(catalog);
    teller.AddSpecialOffer(SpecialOfferType.TenPercentDiscount, toothbrush, 10.0);

    // ACT
    var receipt = teller.ChecksOutArticlesFrom(cart);

    // ASSERT
    Assert.AreEqual(4.975, receipt.GetTotalPrice());
    CollectionAssert.IsEmpty(receipt.GetDiscounts());
    Assert.AreEqual(1, receipt.GetItems().Count);
    var receiptItem = receipt.GetItems()[0];
    Assert.AreEqual(apples, receiptItem.Product);
    Assert.AreEqual(1.99, receiptItem.Price);
    Assert.AreEqual(2.5 * 1.99, receiptItem.TotalPrice);
    Assert.AreEqual(2.5, receiptItem.Quantity);
}
				
			

Anstatt einen Unit-Test zu nutzen, der erfordert, dass ich mich vorher mit dem Code auseinandersetzen muss, kann ich auch einen Approval Test einsetzen. Der ist kürzer und ich kann ihn aufsetzen, ohne den Code verstehen zu müssen. Ausserdem muss ich mich nicht um erwartete Ergebnisse kümmern.

Mit Nuget fügen wir unserem Testprojekt das Paket Verify.Nunit hinzu. Verify baut auf der Funktionalität von Unit-Test Frameworks auf. Daher gibt es je ein Verify Paket für Nunit, Xunit, MS Test und für F# Nutzer auch für Expando. Haben wir das Paket integriert, können wir starten.

				
					[Test]
public async Task Printed_receipt_should_not_change_during_refactoring()
{
    // ARRANGE
    SupermarketCatalog catalog = new FakeCatalog();
    var toothbrush = new Product("toothbrush", ProductUnit.Each);
    catalog.AddProduct(toothbrush, 0.99);
    var apples = new Product("apples", ProductUnit.Kilo);
    catalog.AddProduct(apples, 1.99);

    var cart = new ShoppingCart();
    cart.AddItemQuantity(apples, 2.5);

    var teller = new Teller(catalog);
    teller.AddSpecialOffer(SpecialOfferType.TenPercentDiscount, toothbrush, 10.0);

    // ACT
    var receipt = teller.ChecksOutArticlesFrom(cart);

    // VERIFY
    await Verifier.Verify(receipt.ToString());
}
				
			

Ich lege einen neuen Test an. Wichtig zu beachten: Verify arbeitet asynchron. Den Test benenne ich im Gegensatz zum Unit-Test nicht nach einem speziellen Testfall, sondern nach seinem aktuellen Zweck. Dieser Test soll sicherstellen, dass unser Refactoring das vorhandene Verhalten nicht ändert. Den Code aus den Phasen Arrange und Act kopiere ich einfach. Den dritten Schritt nenne ich nach der genutzten Methode Verify. Die Methode erwartet einen String, also versuche ich die einfachste Version einer Stringrepräsentation des Receipts. Dann führe ich den Test zum ersten Mal aus. Der Test schlägt fehl, eine VerifyException gibt Auskunft darüber und ein Diff-Tool öffnet sich. Links eine Datei mit dem Format [Testklasse].[Testname].received.txt, rechts [Testklasse].[Testname].verified.txt.

Approval Test Diff Output 1 approval tests

Links habe ich in eine Zeile mit dem Ergebnis von Receipt.ToString(). Rechts ist logischerweise alles leer, denn ich habe ja noch kein verifiziertes Ergebnis zum Vergleich. Ich erkenne, dass die ToString() Methode nicht für Receiptüberschrieben wurde. Ich probiere einen anderen Ansatz und schließe das Fenster.

Der Print-Schritt kommt dazu. Ich benötige eine Klasse, die mir eine Stringrepräsentation von dem Receipt Objekt liefert. Die nenne ich ReceiptPrinter. Ich möchte alle Informationen aus dem Receipt anzeigen, mit möglichst wenig Arbeit. Also versuche ich das Receipt als JSON zu serialisieren. Wenn es als Datenklasse aufgebaut ist, ist das ein Einzeiler, der mir erstens alle Daten zurückgibt und zweitens sogar in einer einigermaßen lesbaren Formatierung.

				
					internal static class ReceiptPrinter
{
	public static string PrintReceiptAsJson(Receipt receipt)
	{
	    return JsonSerializer.Serialize(receipt);
	}
}
				
			

Im Print Schritt rufe ich die Methode auf.

				
					// ACT
var receipt = teller.ChecksOutArticlesFrom(cart);

// PRINT
var printReceipt = ReceiptPrinter.PrintReceiptJson(receipt);

// VERIFY
await Verifier.Verify(printReceipt);
				
			

Das Diff-Tool öffnet sich bei Ausführung des Tests und zeigt links nur leere Klammern an.

Approval Test Diff Output 2 approval tests

Ich komme offensichtlich nicht darum herum, mir oberflächlich die Struktur der Receipt Klasse anzusehen. Kontextmenüvorschläge zeigen mir Get Methoden, eine Datumsproperty und weitere Methoden. Das reicht mir schon. Erkenntnisgewinn: die Receipt Klasse ist definitiv ein Kandidat für das Refactoring. Der nächste Versuch, eine Printversion zu schaffen, kombiniert Stringbuilder und Serialisierung.

				
					private static readonly CultureInfo Culture = CultureInfo.CreateSpecificCulture("de-DE");
        
public static string PrintReceiptAsJson(Receipt receipt)
{
    var builder = new StringBuilder();
    var items = JsonSerializer.Serialize(receipt.GetItems());
    var discounts = JsonSerializer.Serialize(receipt.GetDiscounts());
    var total = receipt.GetTotalPrice().ToString(Culture);
    var stamp = receipt.CheckoutTimestamp.ToString(Culture);

    builder.AppendLine(items);
    builder.AppendLine(discounts);
    builder.AppendLine(total);
    builder.AppendLine(stamp);

    return builder.ToString();
}
				
			

Schade, kein Einzeiler mehr. Trotzdem noch ziemlich übersichtlich. Ich habe die 2 Listen mit eigenen Datentypen einfach wieder in den Serialisierer gepackt. Die übrigen Daten werden über die ToString() Methode formatiert. Obacht, ToString()ist kulturabhängig! Ich muss also entscheiden, für alle ToString() Einsätze, eine explizite Kultur zu wählen. Die Tests sind sonst abhängig von der voreingestellten Kultur des Betriebssystems und könnten fehlschlagen. Das Ergebnis kann sich nun sehen lassen.

Approval Test Diff Output 3 approval tests

Die Items und Discounts lassen sich also serialisieren. Ich sehe auch noch eine Datenklasse Product. Die Discounts sind leer. Es gibt einen TotalPrice und ein Datum. Jetzt habe ich die Daten, die mir das Receipt zur Verfügung stellt. Das Ergebnis kann ich speichern. Ich übernehme das linke Ergebnis nach rechts, es wird in der Textdatei mit der Endung *.verified.txt gespeichert und dient uns nun als Referenz für weitere Durchläufe.

Etwas gefällt mir aber nicht. Der Unit-Test, den ich als Vorlage benutzt habe, heisst TenPercentDiscount. Daraus leite ich ab, dass ein solcher Discount sich auch im Receipt wiederfinden sollte. Ich sehe aber keinen Discount in der Ergebnismenge. Das heisst, Discounts werden gar nicht mit abgeprüft. Fehler im Unit-Test? Ich betrachte den Arrange Teil im Test und sehe, dass eine SpecialOffer im Teller für Zahnbürsten eingerichtet wurde. Im Assert wird explizit geprüft, dass die Liste der Discounts leer ist. Das ist mindestens ein unglücklicher Testname, denn der lässt eher darauf schließen, dass ein Discount angewendet wird. Da ist aber keine Zahnbürste im Warenkorb. Also ergänze ich vier Zahnbürsten und starte den Test erneut.

Approval Test Diff Output 4 approval tests

Das Diff-Tool geht auf, linkerseits gibt es neue Informationen. In der ersten Zeile erscheinen die Zahnbürsten. Der Discount wird angezeigt. Der TotalPrice wurde aktualisiert, ebenso wie der Timestamp. Ich verifiziere, die neue Referenz wird gespeichert. Jetzt scheinen alle Daten beisammen zu sein. Ich will den Test grün sehen, führe ihn erneut aus… und das Diff-Tool öffnet sich. Logisch, der Timestamp wurde aktualisiert. Volatile Daten grätschen genauso in einen Approval Test, wie in einen Unit-Test. Ich entscheide, dass dies kein wichtiges zu testendes Element ist. Dahinter steckt keine Logik. Nur ein Aufruf von DateTime.Now, das irgendwo bei der Erzeugung des Receipts gesetzt wird. Ich darf das Element ausklammern. Ich ergänze die PrintReceipt Methode, ersetze den Timestamp durch einen Platzhalter und ergänze bei Timestamp und TotalPrice noch den Text. Das ist lesbarer für alle, die das Diff auswerten müssen. Es ist gute Praxis, Daten nicht einfach zu entfernen, sondern Platzhalter einzubauen. So wird für jeden ersichtlich, dass diese Daten bewusst entfernt wurden. Lesbarkeit und Filtern sind die Aufgaben des Print Schritts.

				
					public static string PrintReceiptAsJson(Receipt receipt)
    {
        var builder = new StringBuilder();
        var items = JsonSerializer.Serialize(receipt.GetItems());
        var discounts = JsonSerializer.Serialize(receipt.GetDiscounts());
        var total = receipt.GetTotalPrice().ToString(Culture);

        builder.AppendLine(items);
        builder.AppendLine(discounts);
        builder.AppendLine($"TotalPrice: {total}");
        builder.AppendLine("CheckoutTimestamp: [DateTime]");

        return builder.ToString();
    }
				
			

Die neue Referenz sieht jetzt so aus:

Approval Test Diff Output 5 approval tests

Damit ist dieser Testfall abgeschlossen. Erneut ausgeführt wird er Grün. Wir haben einen Approval Test gebaut, der den Use Case prüft. Ohne die Logik zu kennen. 100% Testabdeckung haben wir noch nicht, aber es ist nur noch vom Arrange abhängig. Wollen wir beispielsweise andere Discounts prüfen, müssen wir sie nur ergänzen. Alle zugefügten Elemente werden über den Printer ausgegeben. Sonst ist keine weitere Arbeit nötig, ausser der Verifikation.

Dieses Beispiel bezieht sich auf die Prüfung von komplexen Datentypen. Diese lassen sich noch relativ gut, wenn auch aufwändig, mit Assertions prüfen. Sind die Ergebnisse aber noch komplexer, zeigt sich der Nutzen von Approval Tests um so deutlicher. Erzeugte Bilddateien oder PDFs zum Beispiel können sehr einfach getestet werden. Mit Hilfe entsprechender Tools lassen sich eine Menge Daten einfach in eine textuelle Form bringen, die verglichen werden kann. Einen Unit-Test mit Assertions dafür zu schreiben ist schwierig und freudlos. Eine Ergänzung der Test Suite durch Approval Tests ist die sinnvolle Alternative.

Unsere Seminare

course
Clean Code Developer Basics

Prinzipien und Tests – Das Seminar wendet sich an Softwareentwickler, die gerade beginnen, sich mit dem Thema Softwarequalität auseinanderzusetzen. Es werden die wichtigsten Prinzipien und Praktiken der Clean Code Developer Initiative vermittelt.

zum Seminar »
course
Clean Code Developer Advanced

Mit Flow Design von den Anforderungen zum Clean Code – Lernen Sie mit Flow Design einen Softwareentwicklungsprozess kennen, der Sie flüssig von den Anforderungen zum Clean Code führt.

zum Seminar »
course
Clean Code Developer Trainer

Seminare als Trainer durchführen – Dieses Seminar wendet sich an Softwareentwickler, die ihr Wissen über die Clean Code Developer Prinzipien und Praktiken bzw. über Flow Design als Trainer an andere weitergeben möchten.

zum Seminar »
course
Clean Code Developer CoWorking

Online CoWorking inkl. Coaching –
Wir werden häufig gefragt, was man als Entwickler tun könne, um kontinuierlich dran zu bleiben am Thema Clean Code Developer. Unsere Antwort: Treffen Sie sich regelmäßig wöchentlich online mit anderen Clean Code Developern.

zum Seminar »

Kommentar verfassen

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

de_DEGerman