UnitTests 1: Auf die Ausgabe kommt es an!

25. Mai 2012

Dass Unittests schreiben mittlerweile zum guten Ton in der Softwareentwicklung gehört, sollte sich herumgesprochen haben. Platt ausgedrückt: Tests zu haben ist besser als keine Tests zu haben. Aber Test ist nicht gleich Test.

Ich hatte kürzlich das Vergnügen in zwei sehr unterschiedlich gelagerten Projekten mit Tests konfrontiert zu werden, die primär von anderen Entwicklern geschrieben wurden…

Im einen Projekt ging es dabei um eine große Anzahl vorhandener Tests. Tests die über einen längeren Zeitraum und von unterschiedlichen Entwicklern erstellt wurden. Und da es sich um ein Redesign und Refactoring handelte, waren rote Tests zwischenzeitlich keine Seltenheit.

Bei dem anderen Projekt handelte es sich hingegen um eine Neuimplementierung, was natürlich das Testing mit einschließt. Hier traten ganz andere Fragestellungen zutage, etwa bzgl. der grundsätzlichen Teststrategie oder des Einsatzes von Mocking Frameworks. Und natürlich haben Themen eine andere Bedeutung wenn sie zunächst nur eine Hand voll Tests betreffen, als wenn es um dutzende ähnlich gelagerter Fälle geht.

Man kann allerdings einige Dinge von vorne herein ganz anders einplanen, wenn man die Probleme kennt. Deshalb möchte ich einige Erfahrungen hier wiedergeben.

Um Unklarheiten zu vermeiden: Mit “Tests” sind hier reguläre Testprojekte basierend auf MSTest in Visual Studio gemeint. Auch den Begriff “UnitTest” verwende ich eher lax und bezogen auf die Art der Implementierung.

Tests werden zwar geschrieben damit sie grün werden, so wirklich Interessant wird die Sache aber erst im gegenteiligen Fall. Daher soll es in diesem Beitrag schwerpunktmäßig um das Verhalten im Rot-Fall gehen, insbesondere darum, wie man sich die Diagnose vereinfachen kann.

Auf die Ausgaben achten!

Man ändert etwas Grundlegendes und lässt die Tests laufen. Alles grün? Alles gut. Einige Tests rot? … und nun?

Es ist eine typische Erfahrung, dass ein Test zwar aussagt, dass etwas schief gegangen ist – aber um herauszubekommen, was die eigentliche Ursache ist, muss man den Debugger bemühen, was immer auch mit Aufwand verbunden ist. Dabei kann man sich das Leben hier deutliche einfacher machen, wenn man mit den Asserts anders umgeht. Ganz typische Szenarien sind folgende:

  • Zu komplexe Prüfungen in einem Schritt
  • Mehrere Prüfungen in einem Schritt
  • Unklare Ausgaben

Im Detail…

Hinweis: Die Beispiele sind nicht aus den echten Projekten entnommen und auf das jeweilige Problem verkürzt. Sie gehen davon aus, das Daten aus einer Datenbank gelesen werden (was der “reinen Lehre” bzgl. Unittests natürlich widerspricht).

 

Zu komplexe Prüfungen in einem Schritt

Einfaches Beispiel: Daten werden zunächst gelesen, dann verglichen ob der korrekte Datensatz geliefert wurde:

   1: [TestMethod]

   2: public void TestGetUser()

   3: {

   4:     var data = new DataAccess();

   5:  

   6:     // we know the test user!

   7:     var expected = new User { ID = 10, UserName = "TestUser1", Vorname = "Alter", Nachname = "Egon" };

   8:     var actual = data.GetUserInformation("TestUser1");

   9:  

  10:     // Assert.AreEqual doesn't work, but we put the IsEqual method on the User class...

  11:     Assert.IsTrue(expected.IsEqual(actual));

  12: }

Und das Ergebnis des Testlaufs:

image

Das ist eindeutig, der Datensatz stimmt nicht mit dem erwarteten Datensatz überein. Problem dabei: Wo genau der Unterschied liegt wird mir nicht verraten.

Um sich den Vergleich der Entität zu vereinfachen wurde in der Klasse eine Vergleichsmethode implementiert. Grundsätzlich eine gute Idee, denn man will ja nicht immer alle Properties einzeln prüfen müssen:

   1: public partial class User

   2: {

   3:     public bool IsEqual(User other)

   4:     {

   5:         return this.ID == other.ID

   6:             && this.UserName == other.UserName

   7:             && this.Vorname == other.Vorname

   8:             && this.Nachname == other.Nachname;

   9:         // other props ommited for brevity....

  10:     }

  11: }

Dummerweise geht dabei aber die Information verloren, wo der Unterschied liegt. Aber warum nicht diese Vergleichsmethode im Testprojekt belassen und die dort verfügbaren Mechanismen nutzen?

   1: private static void AssertAreEqual(User expected, User actual)

   2: {

   3:     Assert.AreEqual(expected.ID, actual.ID, "User.ID");

   4:     Assert.AreEqual(expected.UserName, actual.UserName, "User.UserName");

   5:     Assert.AreEqual(expected.Vorname, actual.Vorname, "User.Vorname");

   6:     Assert.AreEqual(expected.Nachname, actual.Nachname, "User.Nachname");

   7:     // other props ommited for brevity....

   8: }

Die Schreibarbeit für den Entwickler ist die gleiche, aber der Effekt im Rot-Fall ein ganz anderer:

image

Ausgabe des Properties, samt erwartetem und falschem Wert. Ich muss hier keinen Debugger mehr starten, um zu erkennen, dass im Testcode ein Tippfehler ist (es muss natürlich “Alter Ego” heißen). Wären mir hier falsche Daten aus der Datenbank geliefert worden, wäre das ebenso schnell offensichtlich geworden.

Gerade bei Tests auf Datenkonstellationen (Lesen von Daten, aber auch fachliche Berechnungen oder Validierungen) ist die Aussage, dass etwas schief gegangen ist zwar sehr wichtig (das ist schließlich die Rechtfertigung dafür, den Test überhaupt zu schreiben). Aber mit etwas Sorgfalt hinsichtlich der Ausgabe dessen, was tatsächlich schief gegangen ist kann man sich die Diagnose deutlich einfacher machen.

Das Beispiel hier ist einfach gelagert. Aber bei ausreichend großer Anzahl von Tests, womöglich älter und von anderen Entwicklern geschrieben, kann sich das deutlich auf die Behebungsdauer auswirken. Vor allem, wenn die Fehler in Bereichen auftreten, in denen man sich nicht auskennt, und wenn nicht einer sondern 20 Tests gleichzeitig Rot werden. In diesen Fällen ist man für jede Information dankbar, die einem bei einer schnellen Diagnose hilft.

 

Mehrere Prüfungen in einem Schritt

Ähnlich gelagert ist die Variante, mehrere unterschiedliche Prüfungen in einem Schritt durchzuführen. Insbesondere mit LINQ fällt es sehr einfach, sehr kompakt sehr viele Dinge auf einmal zu tun.

Beispiel: Eine Entität die über einen bestimmten Wert definiert wird muss genau einmal vorhanden sein und wird von einer anderen Entität referenziert:

   1: [TestMethod]

   2: public void TestGetConsistentData()

   3: {

   4:     var data = new DataAccess();

   5:  

   6:     // we know the test user!

   7:     var user = data.GetUserInformation("TestUser1");

   8:     var balanceSheets = data.GetBalanceSheets(1);

   9:  

  10:     // user contains a reference to the one and only default balance sheet

  11:     Assert.IsTrue(balanceSheets.Where(item => item.Rolle == "Laufent").Single().ID == user.LaufendeBilanzID);

  12: }

Und das Ergebnis:

image

Exception? Hat also das Single() zugeschlagen? Oder war womöglich die User-Referenz nicht gesetzt? Die Meldung verrät uns das leider nicht, was für eine Diagnose natürlich schlecht ist.

Das eigentliche Problem ist hier, dass mehr wie eine Prüfung in einem Rutsch durchgeführt werden: Existenz, Eindeutigkeit und Übereinstimmung mit dem Verweis. Besser wäre:

   1: [TestMethod]

   2: public void TestGetConsistentData_Better()

   3: {

   4:     var data = new DataAccess();

   5:  

   6:     // we know the test user!

   7:     var user = data.GetUserInformation("TestUser1");                       

   8:     var balanceSheets = data.GetBalanceSheets(1);

   9:  

  10:     // user contains a reference to the one and only default balance sheet

  11:     var subset = balanceSheets.Where(item => item.Rolle == "Laufent");

  12:     // Check if exactly one balance sheet has been found

  13:     Assert.IsTrue(subset.Count() == 1, "Expect exactly one current balance sheet.");

  14:     // check if it is consistent

  15:     Assert.IsTrue(subset.Single().ID == user.LaufendeBilanzID, "IDs inconsistent.");    

  16: }

Mit folgendem Ergebnis:

image

Hier ist sofort klar, was die Fehlerursache ist. Ein Blick in die Datenbank und auf den Code sollte ganz schnell offensichtlich machen, dass man “laufent” mit ‘d’ schreibt. Die Zeit um die eigentliche Ursache mit dem Debugger herauszufinden hat man schon wieder gespart.

 

Unklare Ausgaben

Das letzte Beispiel zeigt gleich das nächste Problem auf: Kaum ist der Schreibfehler behoben bekomme ich folgende Ausgabe:

image

Die ID passt nicht. Schön. Und weiter? Muss ich wieder den Debugger anwerfen um herauszubekommen, warum das der Fall ist?

Tatsächlich ist dies das häufigste Problem über das ich gestolpert bin: Assert bietet eine ganze Reihe von Prüfmethoden. Statt aber die jeweils adäquate zu verwenden – und damit von den jeweiligen Eigenschaften zu profitieren – bin ich oft auf die Verwendung von Assert.IsTrue() gestoßen. Die Prüfung geht damit natürlich genauso einfach von der Hand, aber das ist mit einem Informationsverlust verbunden. Besser wäre:

   1: [TestMethod]

   2: public void TestGetConsistentData_EvenBetter()

   3: {

   4:     var data = new DataAccess();

   5:  

   6:     // we know the test user!

   7:     var user = data.GetUserInformation("TestUser1");

   8:     var balanceSheets = data.GetBalanceSheets(1);

   9:  

  10:     // user contains a reference to the one and only default balance sheet

  11:     var subset = balanceSheets.Where(item => item.Rolle == "Laufend");

  12:     // Check if exactly one balance sheet has been found

  13:     Assert.AreEqual(1, subset.Count(), "Expect exactly one current balance sheet.");

  14:     // check if it is consistent

  15:     Assert.AreEqual(user.LaufendeBilanzID, subset.Single().ID, "IDs inconsistent.");

  16: }

Hier hat “nur” der Austausch von Assert.IsTrue() gegen Assert.AreEqual() stattgefunden, aber die Ausgabe gibt mir deutlich mehr Information:

image

Das ist sofort ausreichend um in der Datenbank die entsprechenden Datensätze zu suchen und herauszufinden, was für die Inkonsistenz verantwortlich ist. In diesem Falle wurde die falsche User-ID verwendet.

Bei Assert.AreEqual() spielt übrigens die Reihenfolge eine wichtige Rolle: Zuerst der erwartete Wert, dann der tatsächliche. Auch das wird gerne mal nicht so genau genommen.

 

Fazit…

Gerade wenn man mit den ersten Tests anfängt und noch das ganze System im Überblick hat, mag vieles von dem hier angeführten überflüssig erscheinen. Aber wenn ausreichend Zeit vergangen ist wird man diese Tests als Regressionstests verwenden. Und sollten sie dann rot werden, hat man die Details längst vergessen. Oder der Test stammt aus einem Bereich, in dem man selbst keine Aktien hatte.

Dabei muss man gar nicht viel tun, um sich die Arbeit für diese spätere Zeit zu vereinfachen. Die Qualität der Testausgaben zu erhöhen erfordert lediglich etwas Sorgfalt, der Aufwand ist vernachlässigbar. Nur muss man sich die Notwendigkeit erst einmal bewusst machen. Der eigentlich Nutzen stellt sich erst in der Zukunft ein – aber das gilt für automatisierte Tests generell.

 

PS: Das war natürlich nur ein Aspekt zum Thema Unittests; vermutlich wird noch der eine oder andere Beitrag folgen…