Blog

Testing-Anti-Pattern-Kalender 2014 – Mai – Der Freifahrtschein

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

Wer hat noch nicht, wer will nochmal?

Programmieren ist anstrengend. Warum also nicht jede Freifahrt nutzen und bestehende Tests ergänzen?

Warum sich Bezahlen auszahlt, kannst du jetzt nachlesen:

Das Problem

Das Schreiben von Testfällen ist damit verbunden, zeilenweise Code zu erstellen, der nicht „produktiv genommen wird“. Außerdem steht das Verfassen von Testfällen im Ruf, langweilig und zeitraubend zu sein. Schließlich möchte man ja viel lieber unter Einsatz der vollen Kreativität ein Programm schreiben, das etwas Spektakuläres tut, als fertigen Code mit Testfällen zu prüfen.

Dabei ist guter Testcode weder unproduktiv noch langweilig. Das Erstellen von guten Testfällen ist eine kreative Leistung, und Erfolge stellen sich auch schnell ein: nämlich dann, wenn man Fehler findet, die sonst der Kunde gefunden hätte, weil keiner an sie gedacht hat. Und im Sinne einer Investition kann sich das Testen als ausgesprochen produktiv erweisen.

Wir sparen nämlich Zeit und Kosten für:

  • die Behebung unvorhergesehener Bugs während der Entwicklung
  • nicht erfüllte Deadlines
  • Schnellschüsse, um das Projektziel doch noch zu retten, die später wieder ausgebaut werden müssen
  • kostenlose Nachrüstungen
  • das Glätten der Wogen beim Kunden (auch wenn viele Projektmanager das zu lieben scheinen)
  • Zugeständnisse und geschenkte Features, um den Kunden zu besänftigen
  • Überstunden
  • negativen Stress
  • Wartungskosten
  • lange Einarbeitungszeiten für neue Mitarbeiter
  • Projektrüstzeiten
  • hohen Krankenstand

Tests helfen dabei, viele der bekannten Schwierigkeiten bei der Entwicklungsarbeit wenigstens zu verringern. Dies gelingt uns aber nur, wenn wir saubere Tests schreiben und diese auch verantwortungsbewusst pflegen.

Das folgende Beispiel zeigt uns, woran wir erkennen können, dass wir wieder einmal nachlässig waren:

Ein Beispiel

Wir nutzen als Beispiel eine Hotelzimmerverwaltung, die es ermöglichen soll, Zimmer für Gäste zu buchen.

Unser erster Test für die Zimmerbuchung sieht wie folgt aus:

    @Test
    public void testBookRoomForGuest() {
        RoomManager rm = RoomManager.getInstance();

        Room room = rm.getRoom("1");
        Guest guest = new Guest();
        rm.bookRoom(room, guest);
        assertTrue(room.isBooked());
        assertEquals(guest, room.getGuest());
    }
 }

Wir führen den Test einmal aus und er besteht.

Weil das so gut funktioniert hat, wollen wir im nächsten Schritt untersuchen, ob das Zahlungssystem schon mitspielt. Der Geschäftsfall soll eher einfach sein:

* Ein Kunde, der ein Zimmer bucht, generiert eine offene Forderung in Höhe des Zimmerpreises
* Wenn der Kunde das Zimmer bezahlt, wird die offene Forderung auf Null gesetzt.

Die Klasse PaymentsManager soll die Schnittstelle zum Forderungs- und Bezahlmanagement bilden.

Zunächst testen wir die offene Forderung. Weil der vorherige Test schon funktioniert hat und die Vorgänge fachlich stark zusammen hängen, erweitern wir den ersten Test einfach:

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

Das war das Erstellen der Forderung, als nächstes folgt das Bezahlen.

        pm.payRoom(room1, guest, pricePerNight);
        double openPaymentsAfterPay = pm.getOpenPayments(guest);

        assertEquals(openPaymentsAfterPay, 0d, 0);

Damit hätten wir einen Testfall generiert, der den Geschäftsprozess abbildet. Hier ist er noch einmal in voller Schönheit:

    @Test
    public void testBookRoomForGuest() {
        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));

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

        pm.payRoom(room1, guest, pricePerNight);
        double openPaymentsAfterPay = pm.getOpenPayments(guest);

        assertEquals(openPaymentsAfterPay, 0d, 0);
    }

Das sieht zunächst einmal recht kompakt und nachvollziehbar aus. Wenn alle Module korrekt programmiert wurden, besteht der Test auch.

Stellen wir uns aber einmal ein paar Fragen dazu:

Was passiert, wenn das Buchen des Zimmers fehlschlägt?

Das Buchen des Zimmers wird durch die erste Assertion in der Testmethode geprüft. Schlägt diese fehl, werden die anderen Assertions verworfen. Wir wissen zwar, dass wir einen Fehler in der Zimmerbuchung haben, die restliche Anwendung aber bleibt ab diesem Punkt ungetestet.

Haben wir für jede Assertion eine klar definierte Vor- und Nachbedingung?

Nein, haben wir nicht. Jede folgende Assertion arbeitet mit dem Ergebnis eines „gewachsenen“ Zustands. Das wäre im normalen Programmablauf nicht anders. Beim Testen wollen wir uns aber einen bestimmten Abschnitt herausgreifen, für den wir definierte Vor- und Nachbedingungen annehmen. Letzten Endes spezifizieren wir damit eine Erwartungshaltung an die Programmierung.

Die Vorbedingungen stellen den Zustand dar, den vorhergehende Methoden als Ergebnis erzeugen. Die Nachbedingungen bilden die Vorbedingungen für die nächste Methode (nach dem Test ist vor dem Test).

Würden wir auf diese Definition verzichten und in nachgelagerten Tests einfach mit dem Ergebnis der Vorgängermethode weiterarbeiten, verfälschten wir unter Umständen das Verhalten der nachfolgend aufgerufenen Methoden. Die mit falschen Eingabewerten aufgerufene Nachfolgermethode schlüge entweder fehl und rückte somit fälschlicherweise als Verursacher ins Blickfeld, oder sie würde zufällig die fehlerhaften Eingabewerte korrigieren und der Fehler würde nicht entdeckt.

Ein Beispiel: Wenn die Zimmerbuchung ein Zimmer nicht als gebucht markiert und dies fällt erst in der Zahlungsabwicklung auf, könnte man aufgrund des fehlgeschlagenen Tests annehmen, dass das Zahlungsmodul nicht korrekt arbeitet. Nichts ist frustrierender, als den Fehler an der falschen Stelle zu suchen.

Was passiert, wenn eine der aufgerufenen Methoden unerwartete Nebenwirkungen zeigt?

Auch hier haben wir das Problem, dass das Verhalten unvorhersehbar wird und der Verursacher nicht klar identifiziert werden kann.

Was passiert, wenn eines der Module fehlerhaft ist oder noch nicht implementiert wurde?

Die Assertion schlägt hoffentlich fehl. Alle in dieser Methode nachfolgenden Assertions werden dann nicht geprüft. Für den Fall Not yet implemented! gibt es weiter unten noch eine schöne Anregung.

Die Indizien

Woran erkennen wir nun eine Freifahrt?

Assertions

Ein guter Indikator sind die Assertions. Diese sollen ausschließlich am Ende des Testfalls stehen und auch nur Dinge testen, die eng mit dem zu testenden Geschäftsfall zusammenhängen. Und sie sollten sich innerhalb einer Klasse auf dieselben Entitäten beziehen (auf die gleiche Fixture, mehr dazu gleich).

Für unser Beispiel bedeutet dies, dass wir die Tests in mehrere Methoden auslagern.

Nicht eindeutige oder zu große Fixtures

Die in einer Testklasse getesteten fachlichen Entitäten sollen logisch zusammenhängen und einen fachlichen Kontext beschreiben. Man spricht hier von einer Fixture. Eine Testklasse, die den Zusammenhang von Hotelzimmern und Gästen testet, hat in ihrer Fixture ein Hotelzimmer- und ein Gast-Objekt. Eine andere Klasse kann die Bezahlung der Rechnungen testen, dazu gehören dann ein Gast-Objekt und ein Rechnungs-Objekt.

Für jede Fixture soll möglichst eine eigene Testklasse existieren. Können wir einen Testfall mit den fachlichen Entitäten einer Klasse abbilden, so dürfen wir diese Klasse erweitern. Finden wir in den Tests keine passende Fixture, so sollten wir eine neue Klasse schreiben. Die Fixtures sollten so klein wie möglich sein, um Testklassen sinnvoll voneinander abgrenzen zu können.

Für unser obiges Beispiel bedeutet dies, dass das Testen der offenen Forderungen und die Bezahlung der Rechnungen nicht nur in eigene Methoden ausgelagert werden sollte, sondern sogar in eine eigene Klasse.

Kein globales Setup/Teardown pro Testklasse möglich

Kann für alle Methoden einer Test-Fixture (dieser Begriff passt besser als Testklasse) ein allgemeingültiger Ausgangszustand vorausgesetzt werden, so sollte dieser in der Setup-Methode der Testklasse implementiert werden. Für die Hotelzimmerbuchung kann vorausgesetzt werden, dass Zimmer vorhanden sind, für das Bezahlmanagement kann angenommen werden, dass bereits ein Zimmer gebucht wurde.

1:1-Beziehung zwischen Anwendungsklassen und Testklassen

Es ist verlockend, die Testklassen an die Anwendungsklassen zu binden und sie auch entsprechend zu benennen (PaymentsManagerTest). Dies ist für das Testen aber eher ungeschickt und verleitet dazu, viele verschiedene Dinge in einer Testklasse zu prüfen. Besser ist es, die Testklassen an der fachlichen Logik zu orientieren und Fixtures festzulegen.

Die Lösung

Wir zerlegen die obige Testmethode in mehrere kleine Methoden. Jede Methode endet mit einer Assertion.

    @Test
    public void testBookRoomForGuest() {

        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));
    }

    @Test
    public void testOpenPaymentAfterBookingRoom() {
        PaymentsManager pm = PaymentsManager.getInstance();
        RoomManager rm = RoomManager.getInstance();
        Room room1 = rm.getRoom("1");
        Guest guest = new Guest();
        pm.getOpenPayments(guest) ;
        rm.bookRoom(room1, guest);
        double pricePerNight = pm.getPricePerNight(room1);
        double openPayments = pm.getOpenPayments(guest);
        assertEquals(openPayments, pricePerNight, 0d);
    }

    @Test
    public void testPayRoom() {
        RoomManager rm = RoomManager.getInstance();
        Room room1 = rm.getRoom("1");
        Guest guest = new Guest();
        PaymentsManager pm = PaymentsManager.getInstance();
        double pricePerNight = pm.getPricePerNight(room1);

        pm.payRoom(room1, guest, pricePerNight);
        double openPaymentsAfterPay = pm.getOpenPayments(guest);

        assertEquals(openPaymentsAfterPay, 0d, 0d);
    }

Um das Ganze wirklich „rund“ zu machen, verteilen wir unsere Tests noch auf mehrere Klassen, entsprechend der Fixtures.

public class TestHotelBooking {

        private RoomManager rm;
        @Before
        public void setUp() {
            rm = RoomManager.getInstance();
        }

        @Test
        public void testBookRoomForGuestNew() {

            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));
        }

}

Die Logik, die für alle Testmethoden gleich ist (die gemeinsame Vorbedingung der Fixture), lagern wir in die Setup-Methode der Testklasse aus. Im folgenden Beispiel gilt für jeden Test, dass vorher ein Zimmer gebucht sein muss. Da das bereits in der vorhergehenden Fixture getestet wurde, braucht es hier nicht noch einmal wiederholt zu werden.

public class TestPayments {

    private RoomManager rm;
    private Room room1;
    private Guest guest;
    private PaymentsManager pm;

    @Before
    public void setUp() {
        rm = RoomManager.getInstance();
        room1 = rm.getRoom("1");
        guest = new Guest();
        rm.bookRoom(room1, guest);
        pm = PaymentsManager.getInstance();
    }

    @Test
    public void testOpenPaymentAfterBookingRoom() {
        double pricePerNight = pm.getPricePerNight(room1);
        double openPayments = pm.getOpenPayments(guest);
        assertEquals(openPayments, pricePerNight, 0d);
    }

    @Test
    public void testPayRoom() {
        double pricePerNight = pm.getPricePerNight(room1);

        pm.payRoom(room1, guest, pricePerNight);
        double openPaymentsAfterPay = pm.getOpenPayments(guest);

        assertEquals(openPaymentsAfterPay, 0d, 0d);
    }

    @After
    public void tearDown() {
    }

}

Das sieht doch schon deutlich besser aus. Zugegeben: wir haben deutlich mehr Testcode produziert als bei der ersten Variante. Aber dafür können wir jetzt genau sehen, wo es „knallt“, wenn etwas schiefläuft.

Für die einzelnen Testfixtures könnten wir nun recht elegant weitere Tests schreiben. Wie wäre es zum Beispiel mit einer Untersuchung der Grenzfälle? Damit beschäftigen wir uns aber lieber an anderer Stelle.

Not yet implemented: der Erinnerungstest

Hier noch die versprochene Anregung: Manchmal kommt es vor, dass wir ein Feature auslassen, weil es zu speziell, sehr kompliziert oder einfach erst später gefordert ist. Eine Implementierung wird aber von der Schnittstelle oder dem UI-Design gefordert.

    @Override
    public void optimizeDatabaseIndices() {
        //TODO: must be implemented
        LOG.info("Database Index optimization not implemented!!!");
    }

An dieser Stelle neigen viele Entwickler dazu, die Implementierung auf einen späteren Zeitpunkt zu verschieben und leicht verschämt ein TODO-Tag in den Quellcode zu schreiben. Schließlich gibt es in den gängigen IDEs ja auch eine Verwaltung dieser Einträge. Wahrscheinlich ist aber, dass das TODO nie erledigt wird erst mit der Ablösung durch das nächste Produkt verschwindet .

Deutlich wirksamer kann da ein Erinnerungstest sein, der zu einem bestimmten Ablaufdatum plötzlich aufwacht und prompt fehlschlägt.

    @Test
    public void testDatabaseIndexOptimizationForFirstOfMay() {
        Calendar today = Calendar.getInstance();
        Calendar dueDate = new GregorianCalendar(2014, Calendar.MAY, 1);
        assertFalse("Fix the Database Index Optimization!", today.after(dueDate));
    }

Der Test schlägt nach dem angegebenen Datum fehl und erinnert den Entwickler an seine aufgeschobene Arbeit.
Etwas aufwändiger als ein TODO, aber deutlich wirksamer. Gut, oder?

Zusammenfassung

  • Wir haben gesehen, dass es gar nicht so viel mehr Arbeit bedeutet, sauber voneinander abgegrenzte Tests zu schreiben. Man muss sich nur ein paar mehr Gedanken machen und ein paar zusätzliche Methoden und Klassen anlegen.
  • Durch die Zerlegung unserer Geschäftslogik in Fixtures können wir zudem erreichen, dass wir die fachliche Logik ein wenig transparenter machen.
  • Eine als funktionierend getestete Logik dürfen wir dann auch nahezu bedenkenlos in anderen Testfällen verwenden.

In diesem Sinne: 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