Was ist Testgetriebene Entwicklung? 1


Verbreitet die Message!

Die Wartbarkeit von Software hängt von vielen unterschiedlichen Faktoren, wie dem Maß von “Seperation of Concerns”, der Verständlichkeit und der Verwendung von bekannten Entwurfsmustern und Best Practices ab. Eine der ausschlaggebensten Faktoren ist jedoch die Testabdeckung.

Es gibt kaum Softwareentwicklungsmethoden, die im gleichen Maß wie die Testgetriebene Entwicklung (Test Driven Development) auf die Testbarkeit einer Anwendung abzielt. Sie gilt deshalb als ein Softwareentwicklungsvorgehen, das die Wartbarkeit einer Software enorm erhöht und sich gleichzeitig gut bei agilen Entwicklungsmethoden anwenden läst.

Was ist TDD?

Bei der Testgetriebenen Entwicklung wird vor der Implementierung der eigentlichen Funktionalität der Unit-Test für diese Funktionalität geschrieben (tests first). Dies geschieht beispielsweise bei Java mit dem Testframework JUnit. Dabei ist der Test als Spezifikation für die Funktionalität zu verstehen. Liest man also den Code vom automatisierten Test, so kann man daraus die Vor- und Nachbedingungen für die Methode herauslesen.

Hat man den Unit-Test geschrieben, wird dieser ausgeführt. Dieser schlägt natürlich fehl, da man noch keine Funktionalität implementiert hat – weil das in den meisten IDE’s rot markiert wird, nennt man diese Phase RED. Nun kann sich der Entwickler daran machen, die eigentliche Funktionalität der Methode zu entwickeln bis der Test akzeptiert wird. Der dabei entstandene Code muss nicht schön sein, sondern hat als Hauptfokus den Test zu bestehen. Ist dieser bestanden, zeigt die IDE dies grün an (GREEN).

Da der so entstandene Code nicht schön, bzw. clean (Clean Code) sein wird, muss er refactored (REFACTOR) werden.
Beim Refactoring kann man sich jetzt darauf verlassen, dass die Funktionalität erhalten bleibt – denn dafür ist der Unit-Test ja da. Man spricht hier vom RED-GREEN-REFACTOR Zyklus.
Ist der Code nach dem Refactoring von hoher Qualität, kann nun die nächste Funktionalität implementiert werden. Dies beginnt wieder mit dem Schreiben des Tests und dem Betreten des Zyklus.

Beispiel testgetriebener Entwicklung

Nehmen wir als Beispiel die Implementierung einer SingleLinkedList in Java.

Als Schnittstelle sei folgendes Interface gegeben, welches die Grundfunktionalität einer Liste beschreibt:

public interface IList<T> {
    int getSize();
    T getElementAt(int index);

    void removeAt(int index);
    void insert(T element);
}

Hier wäre vor der ersten Codezeile die Überlegung nötig, was denn ein (einfacher) Test einer Grundfunktionalität wäre. Dieser könnte beispielsweise sein, dass eine leere Liste die Größe 0 hat.

@Test    
void emptyListShouldHaveSizeOfZero() {
   SingleLinkedList list = new SingleLinkedList();
   
   // Es wird die Gleichheit mittels der statischen JUnit Methode überprüft
   assertEquals(0, list.getSize()); 
}

Im Anschluss führt man den Test aus. Da es keine Klasse SingleLinkedList gibt, wird dieser nicht kompilieren. Also mocken wir die Klasse SingleLinkedList, welche nur Dummy-Funktionalitäten enthält:

public class SingleLinkedList<T> implements IList<T> {
    private T data;
    private SingleList next;

    public SingleLinkedList(){
      this.data = null;
      this.next = null;
    }

    @Override
    public int getSize() {
        return -1;
    }

    @Override
    public T getElementAt(int index) {
        return null;
    }

    @Override
    public void removeAt(int index) {
    }

    @Override
    public void insert(T element) {
    }
}

Nun wird der Test ausgeführt. Er schlägt rot an. Wir sind in der Phase RED.

Bei näherer Betrachtung müssen wir nun also getSize anpassen. Dabei wählen wir die naive Methode der Iteration über alle verketteten Elemente:

@Override
public int getSize() {
    int size = (this.data==null)?0:1;
    SingleList<T> currentList = this;
    while (currentList.next!=null){
        currentList = currentList.next;
        size++;
    }
    return size;
}

Der Test zeigt grün:

Bei der momentanten Implementierung haben wir jedoch linearen Aufwand, nur um die Größe der Liste herauszufinden. Außerdem ist die Unterscheidung bei welcher Größe wir starten – in Abhängigkeit vom ersten data Element wirklich unschön. Nehmen wir an, wir wollen lieber den Speicherplatz opfern und die Größe der Liste als Property speichern. Daher refaktorieren (REFACTOR) wir die Klasse:

public class SingleList<T> implements IList<T> {

    private SingleList next;
    private int size;

    SingleLinkedList(){
      this.next = null;
      this.size = 0;
    }

    @Override
    public int getSize() {
        return this.size;
    }

  //..
}

Der Test zeigt wieder GREEN.

Da es nun weiter nichts zu refactoren gibt, widmen wir uns dem nächsten Feature: dem Einfügen in die Liste. Eine Spezifikation dieser Funktionalität ist, dass die Größe der Liste der Anzahl der Inserts entspricht. So sieht also unser Test aus:

    @Test
    public void insertShouldIncreaseSize() {

        SingleList<String> list = new SingleList<String>();

        int numOfIterations = (int) (Math.random()*100);

        for (int i =0; i<numOfIterations; i++){
            list.insert("Test" + i);
        }
        
        assertEquals(numOfIterations,list.getSize());
    }

Dieser schlägt fehl (Red). Das war zu erwarten, denn wir haben ja noch keine Insert-Funktionalität implementiert (nur den Mock). Der RED-GREEN-REFACTOR-Zyklus beginnt von vorn. Bereits hier zeigen sich zwei Dinge.

  1. Wir verlassen uns bei diesem Test darauf, dass die Methode getSize korrekt funktioniert. Doch was wenn die Methode eine null-pointer-Exception wirft? Dies können wir mit unseren Tests nicht abdecken, da der vollständige Test aus zwei Teilen besteht: Zuerst prüfen wir, ob eine leere Liste die Größe Null hat. Und dann testen wir, ob die Größe nach jedem Insert um 1 wächst. Nach vollständigem Induktionsprinzip ist damit die Funktion korrekt. Letzteren Test bilden wir zwar mit dem Test “insertShouldIncreaseSize” ab, wir können aber in diesem Test noch nicht sicherstellen, ob insert korrekt funktioniert. Die Katze beißt sich in den Schwanz. Wir müssen die Korrektheit anders sicherstellen, bspw. durch einen Beweis.
  2. Wir verlassen uns bei TDD stillschweigend darauf, dass unsere Tests korrekt sind. Wenn der Test unsere Funktionalität nicht korrekt prüft, gibt es drei Szenarien:
    • Er wirft eine Exception/Kompilierfehler, was bedeutet, das wir genau wissen, dass es am Test selbst liegt.
    • Er zeigt Red, obwohl unsere Funktionalität korrekt ist, was dazu führt, dass wir nach eventuell langem Suchen innerhalb der Funktion auf den Test zurückkommen und diesen korrigieren
    • Er zeigt Green, obwohl unsere Funktionalität nicht korrekt ist – wir haben ein Problem. Ein Problem, das jedoch ohne TDD der Standardfall wäre: Wir glauben unser Code ist richtig und entwickeln weiter, irgendwann fällt uns auf, das irgendwas nicht stimmt und wir haben sehr große Mühe, den Fehler überhaupt zu finden. Es ist also angeraten, dem Test auch mal zu misstrauen. Grün heißt nur: “Es könnte richtig sein.”

Ohne das Beispiel im Detail weiter auszureizen, sollte klar geworden sein, wie TDD funktioniert.

Anmerkungen zum Beispiel:

  1. Das Befüllen der Daten-Liste und numOfIterations sollte in einen BeforeEach-Teil ausgelagert sein.
  2. Ob zufällige Daten in einem Test verwendet werden sollten ist strittig. Vgl. Monkey Testing

Fazit von TDD

Eines der Hauptprobleme Testgetriebener Entwicklung ist der erhöhte anfängliche Aufwand. In diesem Artikel zu den Kosten manuellen Testens bin ich darauf im Detail eingegangen. Ich halte die Aufwände im ökonomischen, aber auch im Sinne eines guten Berufsethos für absolut gerechtfertigt.

Ich habe gezeigt, was Testgetriebene Entwicklung leisten kann – und was nicht. Sie führt, wie wir am Beispiel gesehen haben, zu hochwertigem Code mit hoher Testabdeckung. Ich sehe in TDD zwei Hauptvorteile: Zum einen kann man während der schrittweisen Entwicklung die entstehenden Regressionen erkennen und da man gerade “im Thema steckt”, ist der Aufwand diese zu beheben wesentlich geringer. Zum anderen führt der konsequente Einsatz von TDD dazu, dass überhaupt Tests geschrieben werden – eine Selbstverständlichkeit die nach einigen Entscheidern keine ist. Dennoch ist aus dem Beispiel ebenso hervorgegangen, dass TDD kein Allheilmittel ist, sondern lediglich die Erhöhung der Wahrscheinlichkeit, dass wir guten Code geschrieben haben.

Ich kann aus meiner Berufserfahrung sprechen, dass Projekte in denen Tests wenig oder gar nicht eingesetzt wurden, nicht gut verlaufen sind und plädiere deshalb im Sinne des Kunden, des Projektes, der Kollegen und auch unseres Handwerks an sich für Test-Driven-Development.

Verbreitet die Message!

Schreiben Sie einen Kommentar

Ein Gedanke zu “Was ist Testgetriebene Entwicklung?