Zum Hauptinhalt Zur Navigation

Softwareentwicklung: Wenn testgetriebene Entwicklung einfach nicht gut genug ist

Es ist fast unmöglich, bei Tests alle möglichen Bugs auszuschließen. TDD kann es jedenfalls nicht - mit mutationsgetriebenen Tests stehen die Chancen besser.
/ Rajiv Prabhakar
34 Kommentare News folgen (öffnet im neuen Fenster)
Scheitern gehört zum Konzept bei TDD. (Bild: Pixabay / Montage: Golem.de)
Scheitern gehört zum Konzept bei TDD. Bild: Pixabay / Montage: Golem.de

Dieser Text ist eine Übersetzung. Das Original ist hier(öffnet im neuen Fenster) zu finden.

Als jemand, der gerne über Software-Handwerkskunst und Best Practices spricht, ist testgetriebene Entwicklung(öffnet im neuen Fenster) (Test Driven Development, TDD) für mich ein wunder Punkt. Zunächst einmal: Ich mag es sehr, dass die Betonung bei TDD auf "Testen" liegt. Zu viele Software-Projekte vernachlässigen das Testen - und die Ergebnisse sprechen für sich, wenn Änderungen viele Jahre später exponentiell länger brauchen, um implementiert zu werden, und Programmierer irgendwann Angst bekommen, überhaupt etwas anzufassen .

Trotzdem muss ich sagen, dass ich noch nie ein großer Fan von TDD war. Einerseits ist es zu strikt. Das Beharren darauf, zuerst Tests zu schreiben, steht der explorativen Arbeit oft im Weg - Arbeit, die nötig ist, bevor man herausfinden kann, welche die richtigen Schnittstellen, Methoden und OO-Strukturen sind.

Andererseits ist TDD zu nachsichtig. Viele Praktiker gehen davon aus, dass, weil sie TDD praktizieren, ihre Testsuite grundsolide ist. Ich habe aber schon zu viele Tests gesehen, die mit TDD geschrieben wurden und trotzdem große Abdeckungslücken aufwiesen; Abdeckungslücken, die zu Produktionsfehlern führen können und werden, jetzt oder in der Zukunft. Was die Testmethodik angeht, sollte das Schließen der Abdeckungslücken oberste Priorität sein - und alle anderen Modeerscheinungen und Empfehlungen in den Hintergrund stellen.

Daher wird meine bevorzugte Testphilosophie am besten durch das mutationsgetriebene Testen (Mutation Driven Testing) veranschaulicht, das dieser Abfolge von Schritten folgt:

1. Erreichen Sie einen Zustand, in dem Sie sowohl Code als auch erfolgreich durchlaufende Tests haben.
1.1 Ob Sie zuerst den Code oder zuerst den Test schreiben sollten, sollte an anderer Stelle diskutiert werden. Um an diesen Punkt zu kommen, können Sie gern auch TDD nutzen.

2. Gehen Sie Ihren neu hinzugefügten/geänderten Code Zeile für Zeile durch und setzen Sie manuell einen einzelnen Fehler.
2.1 Bei der Frage, welcher Fehler sich eignet, ziehen Sie vor allem Fehler aus Nachlässigkeit, Faulheit, Unerfahrenheit und Inkompetenz in Betracht, nicht aber böswillige Fehler. Tests zu entwerfen, die böswillige Bugs finden, ist exponentiell schwieriger und nicht sehr realistisch.

3. Überprüfen Sie, ob ein Test jetzt fehlschlägt.

4. Wenn ein Test nicht fehlschlägt, überprüfen Sie, welche Abdeckungslücken Sie in Ihren Tests haben, und beheben Sie diese, indem Sie neue Tests hinzufügen oder Ihre vorhandenen Tests aktualisieren.
4.1 Je besser Sie beim Schreiben von Tests werden, desto seltener sollte das vorkommen.

5. Machen Sie Ihren Fehler rückgängig und überprüfen Sie, ob Ihre Tests jetzt funktionieren.

6. Gehen Sie zurück zu Schritt 2 und wiederholen Sie den Vorgang, bis Sie jeden erdenklichen Fehler eingefügt haben oder Ihnen die Zeit ausgeht.
6.1 Sie werden irgendwann ein Gespür dafür entwickeln, welche Bugs am wahrscheinlichsten an Ihrer Testsuite vorbeikommen. Das wird diesen Prozess stark beschleunigen und Sie werden lernen, umfassendere Tests zu schreiben.

Reklame

Handbuch für Softwareentwickler: Das Standardwerk für professionelles Software Engineering

Jetzt bestellen bei Amazon (öffnet im neuen Fenster)

Die Philosophie: Jeden Fehler manuell testen

Die Philosophie hinter mutationsgetriebenen Tests ist einfach. Denn die einzige Möglichkeit, die Zuverlässigkeit Ihrer Testsuite zu beurteilen, besteht darin, zu sehen, ob sie versagt, wenn ein Fehler vorhanden ist. Also konstruieren Sie diesen Fehler einfach selbst.

Anschließend verwenden Sie das Testergebnis, um herauszufinden, wo Ihre Abdeckungslücken sind, und schließen sie. Und zwar nicht nur, um Ihren spezifischen Fehler abzufangen, sondern auch jede andere ähnliche Kategorie von Fehlern. Indem Sie das tun, können Sie die blinden Flecken in Ihrer Testsuite identifizieren und Ihre Testabdeckung entsprechend verbessern.

Es gibt übrigens Werkzeuge(öffnet im neuen Fenster) , die versuchen, diese Form des mutationsgetriebenen Testens zu automatisieren. Ich freue mich auf den Tag, an dem sie zum Mainstream werden und genauso umfassend sein werden wie die manuellen Mutationen. Für den Moment konzentriert sich dieser Artikel aber auf Letztere.

Sicher schreien jetzt ungefähr eine Million TDD-Befürworter laut auf.

"Aber Sie müssen doch gar keine Mutationstests machen, wenn Sie TDD gemacht haben! Wenn Ihr TDD gut ist, wird jede einzelne Funktionalität einen dedizierten Test haben, also gibt es keine Abdeckungslücken!"

Ein TDD-Beispiel

Um zu veranschaulichen, warum das nicht stimmt, und um die Vorteile von mutationsgetriebenem Testen zu demonstrieren, habe ich das folgende Beispiel zusammengestellt. Die Quelle des Beispiels(öffnet im neuen Fenster) ist das oberste Google-Ergebnis, wenn Sie nach "TDD Example" suchen.

Man könnte argumentieren, dass der Autor dieses Artikels kein gutes Vorbild für TDD ist und dass "kein echter TDD-Praktiker" Tests wie diese schreiben würde. Aber so zu argumentieren, ist nur verletzter Stolz. Der Autor hat sich bemüht, umfassende Tests zu schreiben, hat gute Arbeit geleistet - und das hier gewählte Beispiel ist sehr einfach. Die Realität ist, dass die meisten Praktiker nicht perfekt sind und Dinge übersehen werden. Solche Fehler können mit mutationsgetriebenem Testen erkannt und behoben werden.

Lassen Sie uns also das Beispiel ansehen, es geht um das Erstellen eines einfachen String-basierten Rechners. Der Einfachheit halber betrachten wir nur die ersten drei Anforderungen des Beispiels, zusammen mit ihren Implementierungen und Tests.

Die Anforderungen:

  • Die Methode kann 0, 1 oder 2 durch ein Komma getrennte Zahlen annehmen.
  • Bei einer leeren Zeichenkette gibt die Methode 0 zurück.
  • Die Methode gibt die Summe der Zahlen zurück.

Die Tests:

        private static final TddExample EXAMPLE = new TddExample(); @Test(expected = RuntimeException.class) public final void whenMoreThan2NumbersAreUsedThenExceptionIsThrown() {  EXAMPLE.add("1,2,3"); } @Test public final void when2NumbersAreUsedThenNoExceptionIsThrown() {  EXAMPLE.add("1,2");  Assert.assertTrue(true); } @Test(expected = RuntimeException.class) public final void whenNonNumberIsUsedThenExceptionIsThrown() {  EXAMPLE.add("1,X"); } @Test public final void whenEmptyStringIsUsedThenReturnValueIs0() {  Assert.assertEquals(0, EXAMPLE.add("")); } @Test public final void whenOneNumberIsUsedThenReturnValueIsThatSameNumber() {  Assert.assertEquals(3, EXAMPLE.add("3")); } @Test public final void whenTwoNumbersAreUsedThenReturnValueIsTheirSum() {  Assert.assertEquals(3+6, EXAMPLE.add("3,6")); }

Die Implementierung:

        

public int add(final String numbers) {
  int returnValue = 0;
  String[] numbersArray = numbers.split(",");
  if (numbersArray.length > 2) {
    throw new RuntimeException("Up to 2 numbers separated by comma (,) are allowed");
  }
  for (String number : numbersArray) {
    if (!number.trim().isEmpty()) { // After refactoring
      returnValue += Integer.parseInt(number);
    }
  }
  return returnValue;
}

Das scheint eine gute Testsuite zu sein, die alle Funktionen abdeckt. Aber wie gut ist sie im Vergleich zu mutationsgetriebenen Tests? In der realen Welt würde ich jetzt einen Fehler nach dem anderen einsetzen und die Tests nach jedem einzelnen ausführen. Der Einfachheit halber setzen wir hier jedoch alle relevanten Bugs auf einmal ein.

Mutation 1: Leer vs. Leerzeichen

        if (!number.trim().isEmpty())

Das scheint zu einfach, aber genau deswegen lohnt es sich hinzuschauen. Denn: Was wäre, wenn wir den trim()-Aufruf aus Versehen aus unserer Implementierung entfernen würden? So was kann passieren.

        if (!number.isEmpty())

Mutation 2: Rückgabe von 0 für einen leeren String

        if (!number.trim().isEmpty()) { // After refactoring  returnValue += Integer.parseInt(number); }

Die Anforderung besagt, dass bei einer leeren Zeichenkette 0 zurückgegeben werden soll. Basierend auf der Implementierung des Autors bedeutet das vermutlich, dass jeder leere Substring als 0 behandelt werden soll, während vorangegangene nicht-leere Substrings weiterhin summiert werden sollen. Was aber, wenn die Implementierung etwas anderes macht und 0 zurückgibt, sobald sie eine leere Zeichenkette sieht?

        if (number.trim().isEmpty()) { return 0; } returnValue += Double.parseDouble(number);

Reklame

Handbuch für Softwareentwickler: Das Standardwerk für professionelles Software Engineering

Jetzt bestellen bei Amazon (öffnet im neuen Fenster)

Mutation 3: Drei Eingaben sind nicht erlaubt

        if (numbersArray.length > 2) {  throw new RuntimeException("Up to 2 numbers separated by comma (,) are allowed"); }

Die Anforderung besagt, dass die Methode "0, 1 oder 2 Zahlen" annehmen kann. Sollten wir also vielleicht auch auf drei Zahlen prüfen und dann eine Exception auslösen?

Klar: Das ist ein ziemlich dämlicher Fehler, aber man sollte nie unterschätzen, wie kreativ Menschen beim Fehlermachen sein können.

        if (numbersArray.length == 3) {  throw new RuntimeException("Up to 2 numbers separated by comma (,) are allowed"); }

Mutation 4: Double vs. Int

        returnValue += Integer.parseInt(number);

Bei einem "String -> Numerisch"-Rechner mit Integer-Endergebnis gibt es viele Möglichkeiten, String-Konvertierungen zu implementieren:

1. String in Int konvertieren, Int-Operationen durchführen, ein Int-Ergebnis zurückgeben. Lösen Sie eine Exception aus, wenn der Eingabestring kein Int ist.

2. String in Double konvertieren, Double in Int umwandeln, Int-Operationen durchführen, Int-Ergebnis zurückgeben.

3. String in Double konvertieren, Double-Operationen ausführen, Endergebnis in Int umwandeln und zurückgeben.

Die drei Ansätze erzeugen völlig unterschiedliche Ausgaben, wenn etwa "1.5, 1.5" eingegeben wird. Im Beispiel hat der Autor Option 1 implementiert. Nehmen wir an, das ist tatsächlich das gewünschte Verhalten. Aber was, wenn er fälschlicherweise Option 3 implementiert hätte?

        returnValue += Double.parseDouble(number);

Und jetzt fügen wir alles zusammen

Wie gesagt würden wir in der Praxis nur jeweils einen einzigen Fehler einbauen. Der Einfachheit halber fassen wir aber alle zusammen:

        public int add(final String numbers) {  double returnValue = 0;  String[] numbersArray = numbers.split(",");  if (numbersArray.length == 3) {    throw new RuntimeException("Up to 2 numbers separated by comma (,) are allowed");  }  for (String number : numbersArray) {    if (number.isEmpty()) { return 0; }    returnValue += Double.parseDouble(number);  }  return (int) returnValue; }

Erstaunlicherweise ist nicht ein einziger Test fehlgeschlagen! Jeder einzelne vom Autor geschriebene Test ist immer noch grün, obwohl wir in jede zweite Zeile plausible Fehler eingebaut haben. Das zeigt, dass die Testsuite folgende Lücken hat:

1. Sie testet nicht auf Leerzeichen.

2. Sie testet nicht auf tatsächliche leere Substrings/Leerzeichen-Substrings, wo eigentlich eine Zahl stehen sollte.

3. Sie testet nicht auf eine beliebige Anzahl von Eingaben.

4. Es wird nicht auf numerische, nicht-ganzzahlige Eingaben getestet.

Sobald Sie die oben genannten Lücken identifiziert haben, können Sie zudem anfangen, sie zu schließen, indem Sie weitere Tests hinzufügen. Tests, die jetzt fehlschlagen, aber bestehen, sobald Sie alle eingebauten Fehler beheben:

        

@Test(expected = NumberFormatException.class)
public void doubleInputProvided_shouldThrowException() {
  EXAMPLE.add("1.5,1.5");
}

@Test
public void blankString_shouldReturn0() {
  Assert.assertEquals(0, EXAMPLE.add(" "));
}

@Test
public void emptyStringAfterNumbers_shouldIgnoreIt() {
  Assert.assertEquals(1, EXAMPLE.add("1, "));
}

@Test
public void arbitrarilyManyNumbersProvided_shouldThrowException() {
  StringBuilder inputs = new StringBuilder("3,4");
  for (int i=0; i<10; i++) {
    inputs.append("," + ThreadLocalRandom.current().nextInt());
    try {
      int result = EXAMPLE.add(inputs.toString());
      Assert.fail("No exception thrown. Got result: " + result + ", for input: " + inputs.toString());
    } catch (RuntimeException e) {
      Assert.assertEquals("Up to 2 numbers separated by comma (,) are allowed", e.getMessage());
    }
  }
}

Um es klar zu sagen - die obigen Tests sind sicher nicht perfekt. Mit weiteren Iterationen von mutationsgetriebenen Tests können Sie noch mehr Abdeckungslücken identifizieren, die von den obigen Tests übersehen werden. Außerdem sollten Sie ausgefeiltere Testtechniken verwenden (die hier besprochen werden), um Ihre Testabdeckung auf sinnvolle Weise zu verbessern.

Zumindest aber hilft uns dieser Prozess, besser zu verstehen, wo die Abdeckungslücken sind, und bringt uns der Beseitigung der eklatantesten Lücken näher.

Reklame

Handbuch für Softwareentwickler: Das Standardwerk für professionelles Software Engineering

Jetzt bestellen bei Amazon (öffnet im neuen Fenster)

Aber: Ist es das wert?

Zugegeben, das Durchlaufen des obigen Prozesses wird zusätzlichen Aufwand und Zeit erfordern. Und das Endergebnis wird eine viel umfangreichere Serie von Tests sein, von denen viele für ein ungeschultes Auge überflüssig erscheinen mögen. Ist es das also wirklich wert?

Wie immer hängt es von Ihren Prioritäten ab. Wenn Sie quick and dirty einen Prototyp bauen und es Ihnen nichts ausmacht, dass einige kleine und seltene Fehler durchrutschen, ist das wahrscheinlich in Ordnung. Wenn Sie aber Angst vor Produktionsfehlern haben, sollten Sie unbedingt Zeit und Mühe in die Verbesserung Ihrer Testsuite investieren. Eine solide Testsuite mit minimalen Abdeckungslücken ist die beste Verteidigung gegen Produktionsbugs. Auf lange Sicht wird das Ihre Entwicklungsgeschwindigkeit sogar erhöhen, weil es erlaubt, sicher zu refaktorisieren und Änderungen schnell zu verteilen, ohne viel Zeit für manuelle Tests zu verwenden.

Oft wird über TDD gesprochen, als ob es ein Allheilmittel wäre, welches das Problem mit dem Testen "löst". Das ist ganz klar nicht der Fall. Wenn Sie Jeff Dean oder Sanjay Ghemawat sind, können Sie vielleicht eine perfekte Testsuite allein durch Nachdenken schreiben. Für den Rest von uns Sterblichen ist jedoch der beste Weg, Abdeckungslücken in unserer Testsuite zu identifizieren und zu beheben, indem wir sie empirisch testen.


Relevante Themen