Blog

Testing-Anti-Pattern-Kalender 2014 – Juni – Der Dominoeffekt

In unserer monatlichen Blog-Reihe zu unserem Anti-Pattern-Kalender 2014 zum Thema Testing hier der Kandidat für den Monat Juni: Der Dominoeffekt.

Es macht so viel Spaß, andere mit ins Verderben zu reißen, oder?

Der Dominoeffekt tritt auf, wenn etliche Testfälle eine große Menge an Verhalten in einem Test prüfen. Wenn der Test fehlschlägt, siehst du nur, dass der letzte Stein nicht gefallen ist. Nicht klar ist jedoch, welcher Stein die Ursache für die Unterbrechung der Kette bildet.

Hier kannst du jetzt lernen, wie Du mit weniger Dominosteinen zurecht kommst.

Das Problem

Beim Programmieren erstellen wir viel Logik, die im Hintergrund stattfindet und nicht die Oberfläche erreicht. Etliche Hilfsfunktionen dienen dem Kapseln von Funktionalität (Don’t repeat yourself), dem Zerlegen komplexer fachlicher Logik oder dem Erstellen rekursiver Funktionen. Diese Logik ist oftmals auch nur in Verbindung mit anderer Logik funktionsfähig. Warum sollten wir diese versteckte Logik testen, wo doch nur das Ergebnis zählt?

In modernen Anwendungen arbeiten wir häufig mit einem mehrschichtigen Modell, das unsere Systembereiche innerhalb der verschiedenen Schnittstellen kapselt. Natürlich ist es möglich, innerhalb einer einzigen Methode auf ein Ereignis an der Oberfläche zu reagieren (Benutzer drückt den Suchen-Button), die Ereignisparameter auszuwerten, eine SQL-Datenbankabfrage zu starten und das Ergebnis zurück an die Oberfläche zu schicken.

So werden natürlich keine Anwendungen erstellt. Ein gebräuchliches (wenn auch etwas in die Jahre gekommenes) Architekturmuster ist Model-View-Controller (MVC). Es sieht eine Gliederung der Anwendung in die drei Einheiten Datenmodell (Model), Präsentation (View) und Steuerung (Controller) vor. Die Geschäftslogik wird üblicherweise im Controller umgesetzt, die Geschäftsobjekte nebst Datenbankanbindung finden sich sauber voneinander getrennt im Model und alles Bunte ist in der View-Schicht angesiedelt.

Das Model-View-Controller-Pattern (http://de.wikipedia.org/wiki/Model_View_Controller#mediaviewer/Datei:ModelViewControllerDiagram2.svg)

Das Model-View-Controller-Pattern (http://de.wikipedia.org/wiki/Model_View_Controller#mediaviewer/Datei:ModelViewControllerDiagram2.svg)

Diese Strukturierung hat mehrere Vorteile:

  • Einzelne Module der Anwendung lassen sich leicht tauschen.
  • Wenn das Modell gut umgesetzt ist, kann eine native Oberfläche gegen eine Weboberfläche getauscht werden.
  • Durch Implementierung einiger weniger Schnittstellen kann ein Client gebaut werden (beispielsweise eine Android-App).
  • Manchmal ist es auch erforderlich, spezielle Mechanismen zu tauschen, beispielsweise das Übertragungsprotokoll zwischen einem Geldautomaten und dem Bankrechner.
  • Die Portierbarkeit zwischen verschiedenen Betriebssystemen wird vereinfacht.
  • Die Testbarkeit wird deutlich verbessert durch die Möglichkeit, Module dediziert zu untersuchen.
  • Ein Ausfall einzelner Module kann kompensiert werden.

Ein Beispiel für eine Implementierung des Architekturmusters ist eine Webanwendung. Es gibt eine View, die wahrscheinlich irgendeine JSF-, JavaScript- oder PHP-Technologie verwendet, der Controller wird ein Framework aus den Familien JavaEE, .NET oder AngularJS verwenden (vielleicht schon Scala) und im Model hängt eine passende Datenbank drunter.

Stellen wir uns nun vor, dass wir diese Anwendung testen wollen. Auf uns warten mehrere Fallen:

Fehler Nummer 1: Vertikales Testen durch den Technologiestack

Wir schreiben ein paar Testfälle für die Oberflächenklassen und vertrauen darauf, dass die Fehlfunktionen „sich schon melden“ werden. Im Prinzip testen wir damit den gesamten Technologiestack auf einmal. Wenn kein Fehler zutage tritt, kann dies dennoch folgendes bedeuten:

  • Die Methoden spielen zufällig richtig zusammen. Dies schlägt spätestens dann fehl, wenn wir neue Funktionalität integrieren, die sich nur auf einen Teil der Methoden verlässt.
  • Wir haben zwar Grenzfälle der Oberflächenmethoden getestet, aber nicht die Grenzfälle der darunter liegenden Methoden. Unter Umständen können wir Grenzfälle auch gar nicht nach unten durchreichen.

Wenn ein Fehler zutage tritt, müssen wir uns in aller Regel auf die Suche machen:

  • Manche Fehler sind offensichtlich und wir können diese schnell beheben.
  • Es gibt aber auch Fehler, die aus falsch implementierten Methoden resultieren. Ein Beispiel ist eine Methode, die ein neues Element in eine Sortierung einfügt. Wird das Element an der falschen Stelle eingefügt und die Sortierung nicht durch Testfälle überprüft, so tritt dieser Fehler irgendwann dann auf, wenn sich eine Methode auf die Sortierung verlässt. Rechenfehler, falsche Entscheidungen und Endlosschleifen können die Folge sein und Testfälle schlagen manchmal auf scheinbar unerklärliche Weise fehl. Wenn dieses bereits im Test passiert, müssen wir uns auf die Suche machen, mit Logausgaben und allem. Eigentlich wollten wir das mit den Tests vermeiden.

Fehler Nummer 2: Viele Testfälle in einer Methode

Hier ein Extrembeispiel aus unserer Hotelbuchung (siehe dazu auch das Beispiel aus dem Mai):

    @Test
    public void testBookRoomForGuest() {
        de.holisticon.antipatterns.hotelbooking.RoomManager rm = RoomManager.getInstance();

        Room room1 = rm.getRoom("1");
        Guest guest = new Guest();
        rm.bookRoom(room1, guest);
        assertTrue(room1.isBooked());
        assertEquals(guest, room1.getGuest());
        assertEquals(1, rm.countBookings(room1));
        assertEquals(1, rm.countBookings(null));

        // wie praktisch, das Setup ist schon da
        // dann fügen wir doch einfach noch einen Test an
        Room room2 = rm.getRoom("2");
        assertFalse(room2.isBooked());
        Guest guest2 = new Guest();
        rm.bookRoom(room2, guest2);
        assertTrue(room2.isBooked());
        assertEquals(guest2, room2.getGuest());
        assertEquals(1, rm.countBookings(room2));
        assertEquals(2, rm.countBookings(null));

        // jetzt prüfen wir nochmal, wie sich das mit
        // dem Stornieren einer Buchung verhält
        rm.cancelRoom(room2, guest2);
        assertFalse(room2.isBooked());
        assertNull(room2.getGuest());
        assertEquals(0, rm.countBookings(room2));
        assertEquals(1, rm.countBookings(null));

        //Und nun prüfen wir nochmal, ob der freigewordene Gast ein anderes Zimmer buchen kann
        Room room3 = rm.getRoom("3");
        assertFalse(room3.isBooked());
        rm.bookRoom(room3, guest2);
        assertTrue(room3.isBooked());
        assertEquals(guest2, room3.getGuest());
        assertEquals(1, rm.countBookings(room3));
        assertEquals(2, rm.countBookings(null));
    }

Unser Testfall bildet zunächst zwei Zimmerbuchungen ab und dann noch eine Umbuchung. Das sind drei (!) Geschäftsfälle. Es ist zwar schön zu wissen, dass das funktioniert, wenn aber einer der Asserts nach false auflöst, wird der gesamte Testfall verworfen. Wo war nun der Fehler?

Fehler Nummer 3: Testfälle verlassen sich auf Ergebnisse anderer Methoden

Sehen wir uns dazu einmal den folgenden Codeabschnitt an:

       PaymentsManager pm = PaymentsManager.getInstance();
       double pricePerNight = pm.getPricePerNight(room1);
       double openPayments = pm.getOpenPayments(guest);
       assertEquals(openPayments, pricePerNight);

Es wird geprüft, ob eine offene Forderung nach dem Buchen eines Zimmers dem Zimmerpreis pro Nacht entspricht. Im Prinzip richtig. Wenn das Zimmer 55 Euro kostet, schuldet der Gast auch 55 Euro.

Woher wissen wir denn, dass der Zimmerpreis mit 55 Euro zurückgeliefert wird? Und wenn nein: ist dann nicht die zu prüfende Methode falsch? Eigentlich prüfen wir hier, ob x == y ist.

Es ist also schon fast wichtiger, die darunter liegende Logik ausführlich zu testen, denn diese bildet das Fundament unserer Anwendung! Je näher wir uns beim Testen an den potenziellen Fehlerquellen befinden, desto größer sind unsere Chancen, funktionierende Software auszuliefern. Wir müssen natürlich nicht jeden Getter und Setter testen, die Herausforderung besteht auch darin, einen guten Kompromiss zwischen Testabdeckung und Aufwand zu finden.

Die Indizien

Wir beobachten eines oder mehrere dieser Phänomene:

  • Unsere Testfälle schlagen auf unerklärbare Weise fehl.
  • Wir suchen sehr lange nach dem Fehler und müssen Logausgaben einbauen.
  • Die Behebung ist offensichtlich, zieht aber Folgefehler nach sich, der Test wird nie grün.
  • Mehrere Asserts in einem Testfall.

Was können wir jetzt besser machen?

  • Wir sollten nach Möglichkeit jede Methode ausführlich und auf ihre Grenzfälle testen. Hier ist ein Kompromiss zu finden zwischen Testabdeckung und Testaufwand, sicherlich muss nicht jeder Getter und Setter getestet werden. Eine Methode, die Vereinigungsmengen auf Binärbäumen bildet, darf hingegen schon etwas ausführlicher geprüft werden.
  • Wenn wir sehr lange nach einem Fehler suchen, sollten wir die beteiligten Methoden ausführlicher testen.
  • Wenn viele Asserts in einem Testfall sind, dann sollten wir den Testfall in mehrere kleinere Tests zerlegen. Es spricht auch absolut nichts dagegen, Testcode zu duplizieren und das @Test-Tag häufig zu verwenden. Tests sollten auch immer autark lauffähig sein.
  • Wenn die Behebung Folgefehler nach sich zieht und der Test nie grün wird, sollten wir dies im schlimmsten Fall ehrlich nach vorne kommunizieren und Deadlines reißen. Dies ist für das Projekt immer noch besser, als fehlerhaften Code auszuliefern.
  • Unsere Testfälle sollten den erwarteten Wert nicht von einer anderen Methode zurückliefern lassen. So wäre es richtig:
            PaymentsManager pm = PaymentsManager.getInstance();
            double pricePerNight = pm.getPricePerNight(room1);
            double openPayments = pm.getOpenPayments(guest);
            assertEquals(pricePerNight, 55.0d);
            assertEquals(openPayments, 55.0d);
    

    Das sieht schon besser aus. Eigentlich sind das schon wieder zu viele Testfälle pro Methode, wir können aber noch relativ gut zuordnen, wo der Fehler herkommt.
    Wichtig ist hier auch der Ausdruck 55.0d. Wir geben damit die Präzision und den Datentyp vor, den wir erwarten (für diejenigen, die es noch nicht nicht wussten: Wir können einer Zahl ihren Datentyp mitgeben, also: 55 = Integer, 55L = Long, 55.0D = Double, etc.).

Nach wie vor gilt natürlich: Rote Testfälle dürfen nicht eingecheckt werden!

Apropos Grenzfälle …

Mein Lieblingsbeispiel für einen Grenzfall ist übrigens das Parsen einer CSV-Datei, zum Beispiel einer mit Buchtiteln:

#Author, Title, Pages
Cay Horstmann, Scala for the impatient, 360
Mustermann, Lorem, Ipsum, Dolor, 220

Der zweite Buchtitel, das philosophische Werk „Lorem, Ipsum, Dolor“ wird wohl eine NumberFormatException werfen. Hätten wir beim Testen nur daran gedacht …

Kommen wir zum Schluss

Damit sind wir am Ende unseres heutigen Blogbeitrags angekommen, der dieses Mal auch etwas spät dran war (nicht nur beim Testen gibt es Dominoeffekte … ). Wir haben einiges gelernt über kaskadierende Erscheinungen, hinter denen sich die wirklichen Fehler verstecken und in der Tiefe lauern, bis jemand sie ans Tageslicht holt. Warten wir also nicht darauf, dass die Büchse der Pandora geöffnet wird, sondern schaffen wir lieber gleich ein ordentliches Testfundament. In diesem Sinne wünschen wir dir: gutes Testen!

Siehe dazu auch James Carrs fantastischen Blogbeitrag zu TDD-Anti-Patterns und die dazugehörige Diskussion auf der Test-Driven Development Yahoo Group.

 

Über den Autor

Antwort hinterlassen