Blog

Testing-Anti-Pattern-Kalender 2014 – April – Der Schmutzfink

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

Ich bin doch keine Putzfrau!

Ja, Aufräumen ist lästig. Beim Testen aber unabdingbar. Der Schmutzfink hält davon nichts und hinterlässt so einiges in den Systemen.

Das Problem

Warum denn nach dem Testen den Ursprungszustand wieder herstellen? Gute Tests sollten damit umgehen können!

Was erwarten wir von unseren Tests, wenn wir sie wiederholt ausführen? Dass sie immer zum gleichen Ergebnis kommen. Unsere Tests sollen zuverlässige Aussagen darüber treffen, dass Zustand A zu Verhalten B führt – und das bei jeder Ausführung.

Natürlich muss A dann auch immer A sein. Die Tests dürfen das System nicht verändern. Wenn aus A nach dem Test B oder C geworden ist, hat hier wahrscheinlich jemand nicht richtig aufgeräumt – der Schmutzfink war am Werk.

Wenn wir Tests haben, die mal funktionieren und mal nicht, können wir uns erst einmal auf die Fehlersuche machen. Der Fehler kann überall sein: im zu testenden Code, aber auch im Testfall. Uns bleibt also nichts anderes übrig, als das System genauer zu analysieren, um zu verstehen, was hier gerade schief läuft. Das kostet Zeit und Nerven. Wir sollten daher darauf achten, unsere Tests sauber aufzusetzen und nach der Testausführung wieder aufzuräumen – und das ist gar nicht so schwer.

Ein Beispiel

Im folgenden Beispiel wollen wir eine Hotelzimmerverwaltung entwickeln, die es ermöglichen soll, Zimmer für Gäste zu buchen.

Der erste Test für die Zimmerbuchung kann wie folgt aussehen:

    @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 funktioniert. Da der RoomManager eine Datenbank verwendet, meldet das System bei der nächsten Ausführung, dass das Zimmer bereits belegt ist. Warum ist das so? Wir haben vergessen, die Buchung nach dem Test wieder zu löschen. Aus dem vorliegenden Quellcode ist übrigens auch nicht ersichtlich, ob und wohin der RoomManager die Buchungen persistiert. Das sollten wir beim Testen aber wissen.

Die Indizien

  • Tests funktionieren manchmal und dann wieder nicht
  • Systemparameter und -einstellungen verändern sich hin und wieder auf unerklärliche Weise
  • Der Datenbestand wächst beim Testen an

Die Lösung

Beim Testen kommen wir häufig nicht drum herum, Logik zu testen, die bestehende Systeme verändert.

Veränderungen können zum Beispiel durch folgende Aktionen eingeleitet werden:

  • wir persistieren einen Zustand, indem wir Daten in eine Datenbank oder ins Dateisystem schreiben.
  • wir sprechen über eine Kommunikationsschnittstelle ein Drittsystem an, von dem wir nicht wissen, ob es durch die Anfrage seinen Zustand verändert.
  • wir nehmen Einfluss auf Systemparameter oder Konfigurationseinstellungen.

Zum Glück gibt es ein paar Möglichkeiten, damit umzugehen.

Zunächst einmal sollten wir prüfen, was hier getestet werden soll: Nur die Geschäftslogik oder das Zusammenspiel der involvierten Services?

  • wenn die involvierten Services und Schnittstellen für den Test relevant sind, müssen wir nach dem Testen wieder aufräumen. Und auch vor dem Testen bietet es sich an, „klar Schiff“ zu machen.
  • wenn unsere Tests wirklich nur die Geschäftslogik prüfen, können wir die Schnittstellen des Systems auch „mocken“, also geeignet simulieren.

Vor dem Testen erstmal Ordnung schaffen

Bevor wir testen, sollten wir einen Zustand herstellen, in dem wir garantieren können, dass unsere Tests funktionieren.
Hierfür gibt es die Annotation @Before. Eine mit dem Tag @Before versehene Testmethode wird vor jedem Testfall ausgeführt.
Beispielsweise können wir vor dem testweisen Erstellen von Buchungen alle Buchungsdatensätze löschen.

    @Before
    public void setUp() {
        RoomManager.getInstance().clearAllBookings();
    }

Dass wir nicht auf der Produktivdatenbank testen, dürfte jetzt klar sein :)

Nach dem Test aufräumen


In dem oben dargestellten Test erzeugen wir mit rm.bookRoom(room, guest); eine Buchung. Wenn die Methode das Buchungsobjekt zurückgibt, können wir diese beim Testen sammeln und hinterher wieder aufräumen.

Wir könnten nun am Ende des Testfalles die Buchung wieder löschen.

    @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());
        RoomManager.getInstance().deleteBooking("1");
    }

Damit vermischen wir aber Testfall und Testnachbereitung.

Besser ist es, die erstellten Buchungen zu sammeln und das Löschen nach dem Test erledigen zu lassen.

    private List<Booking> bookings = new ArrayList<Booking>();

    @Before
    public void setUp() {
        RoomManager.getInstance().clearAllBookings();
    }

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

        Room room = rm.getRoom("1");
        Guest guest = new Guest();
        bookings.add(rm.bookRoom(room, guest)); // Vorsicht, das ist nicht null-safe
        assertTrue(room.isBooked());
        assertEquals(guest, room.getGuest());
    }

    @After
    public void tearDown() {
        for (Booking b : bookings) {
            RoomManager.getInstance().deleteBooking(b);
        }
        bookings.clear();
    }

Die mit @After getaggte Methode wird nach jedem Testfall ausgeführt. Wir haben mit @Before und @After also eine Möglichkeit, unsere Testfälle vor- und nachzubereiten.

Unter Umständen haben wir jetzt nicht den echten Ursprungszustand wiederhergestellt. Wenn die Buchungstabelle in der Datenbank mit AutoIncrement-Ids arbeitet, so zählt jeder unserer Tests die Ids hoch. Das sollte man bei diesem Vorgehen im Hinterkopf behalten.

Mocken des Datenbankzugriffs


Im Folgenden stellen wir eine recht elegante Methode vor, die Geschäftslogik unabhängig von ihrer Umgebung zu testen.

Konkret bedeutet Mocking, dass wir die Datenbankzugriffsklasse durch eine Klasse ersetzen, die den Datenbankzugriff simuliert. Sie tut also nur so, als würde sie auf die Datenbank zugreifen. Idealerweise legt dieses Objekt bei der Initialisierung auch noch ein paar Stubs, also Dummy-Daten an (oder greift auf solche zu).

public class RoomManager {

// das gute alte Singleton
    private static RoomManager instance = new RoomManager();

    private Database database;

    private RoomManager() {
    }

// wenn nicht anders angegeben, arbeite mit der Default-Datenbank
    public static RoomManager getInstance() {
        instance.useDefaultDatabase();
        return instance;
    }

// benutze die übergebene Datenbankinstanz
    public static RoomManager getInstance(Database db) {
        instance.setDatabase(db);
        return instance;
    }

…

}

Unser Datenbank-Mock sieht dann so aus:

public class DatabaseMock implements Database {

// das gute alte Singleton
    private static Database instance = new DatabaseMock();

// die Room-„Tabelle“
    Map<String, Room> roomMap = new HashMap<String, Room>();

// die Dummydaten
    Set<Booking> bookingSet = new HashSet<Booking>();
    {
        roomMap.put("1", new Room());
        roomMap.put("2", new Room());
        roomMap.put("3", new Room());
    }

    private DatabaseMock() {
    }

    public static Database getInstance() {
        return instance;
    }

// Implementierungen der Datenzugriffsmethoden
    @Override
    public void createBooking(Booking booking) {
        bookingSet.add(booking);
    }

    @Override
    public Room getRoom(String roomNumber) {
        return roomMap.get(roomNumber);
    }

    @Override
    public void deleteBooking(Booking booking) {
        bookingSet.remove(booking);
    }

    @Override
    public void deleteBookings() {
        bookingSet.clear();
    }

}

Unsere Testklasse kann nun einfach den Mock verwenden:

public class TestHotelBookingWithMocking {

    @Before
    public void setUp() {
        RoomManager.getInstance(DatabaseMock.getInstance());
    }

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

    @After
    public void tearDown() {
         RoomManager.getInstance().deleteBookings();
    }

}

Wenn wir unser Mock-Objekt vor jedem Testlauf neu initialisieren, brauchen wir die tearDown()-Methode eigentlich nicht mehr. Wir erzeugen uns ja jedes Mal einen neuen Datenbestand. Das sei aber dem Entwickler überlassen.

Nun haben wir Folgendes erreicht:

  1. Wir haben gelernt, wie wir vor dem Testen unsere Ausgangsbedingungen festlegen können
  2. Wir wissen, wie wir nach dem Testen wieder aufräumen
  3. Wir haben gesehen, wie wir unsere Tests durch Mocking sauber kapseln können
  4. Mit den Mock- oder Dummy-Objekten haben wir außerdem eine Spezifikation darüber geschaffen, wie die Objekte, die von der echten Datenbankschnittstelle geliefert werden, aussehen sollten.

Zusammenfassung

Wir haben gesehen, dass es gar nicht so schwer ist, beim Testen Ordnung zu schaffen. Wir können sogar so weit gehen, dass wir erst die @Before und @After-Tags hinschreiben und uns dann Gedanken über die Tests machen.

Eigentlich verhält es sich mit dem Testen ähnlich wie mit dem Aufräumen zu Hause: wenn wir nicht auf Ordnung achten und ständig alles hinter uns liegen lassen, müssen wir jedes Wochenende erstmal aufräumen. Wenn wir aber gleich alles wieder wegräumen, haben wir am Wochenende mehr Zeit, in der Sonne zu liegen.

In diesem Sinne: gutes Testen!

Für das Mocking bei Modultest gibt es auch eine Reihe fertiger Frameworks. Eine Sammlung findet sich in dem folgenden Wikipedia-Artikel:  http://de.wikipedia.org/wiki/Mocking_Framework

Über den Autor

Antwort hinterlassen