Softwareentwicklung: Testgetriebene Entwicklung nach der St. Pauli Schule

Die St. Pauli Schule der testgetriebenen Entwicklung ähnelt der Münchner Schule, verzichtet aber auf den komplexen Einstiegstest. Kann das gelingen?

In Pocket speichern vorlesen Druckansicht 7 Kommentare lesen

(Bild: Blackboard/Shutterstock.com)

Lesezeit: 12 Min.
Von
  • Marco Emrich
Inhaltsverzeichnis

Testgetriebene Entwicklung (Test Driven Development, TDD) ist eine beliebte und nützliche Methodik. Richtig angewendet reduziert TDD die Fehlerdichte signifikant, ohne dabei Produktivitätsverluste in Kauf nehmen zu müssen. Vor allem hilft TDD, Softwaredesign zu verbessern.

Der Artikel über die Münchner Schule auf heise Developer hat die unterschiedlichen Stile beziehungsweise Schulen für testgetriebene Entwicklung aufgezeigt. Sechs Vertreter stechen besonders heraus, da sie bekannt und ihre Konzepte gut dokumentiert sind:

Die St. Pauli Schule ist mit der Münchner Schule verwandt. Anfangs war sie nur als scherzhafte Antwort darauf gedacht, hat sich aber mittlerweile zu einem eigenen nützlichen TDD-Stil entwickelt. Die St. Pauli Schule zeichnet sich durch sechs Regeln aus:

  • Starte auf dem API-Level,
  • wachse langsam und stetig,
  • delegiere Unterprobleme an Stubs,
  • ersetze Stubs rekursiv,
  • überprüfe die Allgemeinheit der Lösung durch einen Validierungstest und
  • behandle die Test-Suite als "append-only" (nachträgliche Veränderungen sind nicht zulässig).

Einer der wesentlichen Unterschiede zur Münchner Schule besteht im ersten Testfall. Die Münchner Schule verwendet einen komplexen Anfangstest, der den größten Teil der Anforderungen aus der Problemstellung abdeckt. St. Pauli möchte dagegen wie London oder Chicago mit einem möglichst einfachen Testfall beginnen.

Um die Vorgehensweise der St. Pauli Schule zu verdeutlichen, bietet sich wie im Artikel zur Münchner Schule die bekannte Programmierübung Diamond-Kata an, die auch der Gründer der St. Pauli Schule Steven Collins als Beispiel verwendet. Die identische Problemstellung hilft dabei, die Unterschiede zur Münchner Schule besser zu erkennen. Als Programmiersprache kommt JavaScript zum Einsatz und als Testframework Jest. Die Übung hat folgende Aufgabenstellung:

Ziel: Zu einem gegebenen Buchstaben wird eine Raute ausgegeben, die mit "A" beginnt und den angegebenen Buchstaben an der breitesten Stelle hat.

diamond("B") erzeugt

.A.
B.B
.A.

diamond("C") gibt Folgendes aus:

..A..
.B.B.
C...C
.B.B.
..A..

Hinweis: Zur besseren Lesbarkeit verwendet der Beispielcode Punkte (.) statt Leerzeichen.

Die erste Regel besagt: "Starte auf dem API-Level". Dabei ist nicht etwa eine API im Sinne von REST oder einer speziellen Schnittstelle gemeint. Stattdessen ist die API abhängig vom jeweiligen SUT (Subject under Test). Es kann sich unter anderem um einen Service, eine Komponente oder eine Reihe von Funktionen handeln. Die Abstraktionshöhe ist von Fall zu Fall unterschiedlich.

Während die Münchner Schule mit einem komplexeren Fall wie dem Diamanten zu "C" anfängt, beginnt St. Pauli mit dem einfachen "A"-Diamanten:

describe("diamond", () => {
  it("should return the A-Diamond ", () => {
    expect(diamond("A")).toEqual("A");
  });
});

Der nächste Schritt ist ähnlich zu München: Statt einer echten Implementierung sorgt ein Fake beziehungsweise Stub dafür, dass der Code den Test besteht. Der Begriff Stub ist etwas anderes als der Mock-ähnliche Test-Stub aus der Kategorie Test-Double. Ein Fake oder Stub ist ein Stück Produktionscode, der für den speziellen Testfall die richtige Lösung vortäuscht:

const diamond = (letter) => "A";

Es folgt der nächste Testfall auf API-Level:

it("should return the B-Diamond ", () => {
  expect(diamond("B")).toEqual(".A.\nB.B\n.A.");
});

Für diesen Fall ist es erforderlich, den Code zu erweitern:

const diamond = (letter) => {
  switch (letter) {
    case "A":
      return "A";
    case "B":
      return ".A.\nB.B\n.A.";
  }
};

Sobald zwischen den Fällen ein Muster erkennbar ist, lässt es sich durch Refactoring extrahieren. Da das noch nicht der Fall ist, folgt ein dritter Test mit dem dazugehörigen Fake für den "C"-Fall.

it("should return the C-Diamond ", () => {
  expect(diamond("C")).
    toEqual("..A..\n.B.B.\nC...C\n.B.B.\n..A..");
});

const diamond = (letter) => {
  switch (letter) {
    case "A":
      return "A";
    case "B":
      return ".A.\nB.B\n.A.";
    case "C":
      return 
        "..A..\n.B.B.\nC...C\n.B.B.\n..A..";
  }
};

In dem Code sind unterschiedliche Muster erkennbar, die ein Refactoring extrahieren kann. Kent Beck nennt das Prinzip, bei dem man erst mehrere Fälle über Verzweigung "faked" und anschließend Gemeinsamkeiten herauszieht, Triangulation [1]. Es ist der treibende Motor der St. Pauli Schule.

Eine Ersetzung der \n-Zeichen durch echte Zeilenumbrüche hebt die Muster hervor:

.A.
B.B
.A.

..A..
.B.B.
C...C
.B.B.
..A..

...A...
..B.B..
.C...C.
D.....D
.C...C.
..B.B..
...A...

Unter anderem fällt auf, dass der Diamant immer aus zwei Hälften (halbe Dreiecke beziehungsweise Pyramiden) besteht und die untere Hälfte ein Spiegelbild der oberen darstellt. Dazwischen gibt es eine Mittellinie, bei der sich der Buchstabe an den äußeren Enden befindet. Beide Muster sind ein Ansatzpunkt für den nächsten Schritt. Collins extrahiert in seinem Beispiel eine Pyramidenfunktion. Dieser Artikel geht stattdessen zunächst dazu über, die Mittellinie zu extrahieren. Beide Ansätze funktionieren gleichermaßen, aber das Extrahieren der Mittellinie vereinfacht den Vergleich mit dem Artikel zur Münchner Schule.

Zunächst gilt es, die Struktur im Code deutlicher sichtbar zu gestalten. Ein Refactoring zerlegt die Strings in Arrays, um sie anschließend mit join wieder zusammenzusetzen. Das Vorgehen ähnelt dem im Beispiel der Münchner Schule, betrifft allerdings mehrere Zweige in der switch-Anweisung.

const diamond = (letter) => {
  switch (letter) {
    case "A":
      return "A";
    case "B": {
      return [".A.", "B.B", ".A."].join("\n");
    }
    case "C": {
      return 
       ["..A..", ".B.B.", "C...C", 
        ".B.B.", "..A.."].join("\n");
    }
  }
};

Wer es erkennt, könnte sofort Tests für eine middleLine-Funktion schreiben. Ansonsten hilft es, zunächst eine Konstante zu extrahieren:

    case "B": {
      const middleLine = "B.B";
      return 
        [".A.", middleLine, ".A."].join("\n");
    }
    case "C": {
      const middleLine = "C...C";
      return 
        ["..A..", ".B.B.", middleLine, 
         ".B.B.", "..A.."].join("\n");
    }

Spätestens jetzt ist klar, dass sich bei den nächsten Schritten das Triangulationsmuster auf niedriger Ebene wiederholt:

describe("middleLine", () => {
  it("should return a middleLine for B", () => {
    expect(middleLine("B")).toEqual("B.B");
  });
});


const middleLine = (letter) => {
  switch (letter) {
    case "B": {
      return "B.B";
    }
  }
};


describe("middleLine", () => {
  it("should return a middleLine for B", () => {
    expect(middleLine("B")).toEqual("B.B");
  });
  it("should return a middleLine for C", () => {
    expect(middleLine("C")).toEqual("C...C");
  });
});


const middleLine = (letter) => {
  switch (letter) {
    case "B": {
      return "B.B";
    }
    case "C": {
      return "C...C";
    }
  }
};

Als nächstes Refactoring lässt sich der Parameter letter einsetzen, um den konstanten String "B" oder "C" zu ersetzen. Ziel ist es dabei immer, die einzelnen Fälle aneinander anzugleichen.

const middleLine = (letter) => {
  switch (letter) {
    case "B": {
      return letter + "." + letter;
    }
    case "C": {
      return letter + "..." + letter;
    }
  }
};

Nun fällt auf, dass eine spaces-Funktion hilfreich wäre, die Leerzeichen beziehungsweise Punkte generiert. Die Umsetzung erfordert lediglich einen TDD-Schritt:

describe("spaces", () => {
  it("should return a given number of spaces", () => {
    expect(spaces(5)).toEqual(".....");
  });
});


const spaces = (n) => ".".repeat(n);

Das Muster dazu heißt Obvious Implementation und stammt ebenfalls von Kent Beck [1]. Die Funktion zeigt den letzten verbliebenen Unterschied deutlich auf:

const middleLine = (letter) => {
  switch (letter) {
    case "B": {
      const numberOfSpacesInMiddleLine = 1;
      return letter + 
      spaces(numberOfSpacesInMiddleLine) + letter;
    }
    case "C": {
      const numberOfSpacesInMiddleLine = 3;
      return letter + 	
        spaces(numberOfSpacesInMiddleLine) + letter;
    }
  }
};

Die Anzahl der Leerzeichen in der Mittellinie ist unterschiedlich. Dabei wiederholt sich der Prozess und führt schließlich zu folgender Implementierung:

describe("numberOfSpacesInMiddleLine", () => {
  it("should return the number of spaces " +  
     "for letter B", () => {
    expect(numberOfSpacesInMiddleLine("B")).toEqual(1);
  });
  it("should return the number of spaces " +
     "for letter C", () => {
    expect(numberOfSpacesInMiddleLine("C")).toEqual(3);
  });
});

 
const numberOfSpacesInMiddleLine = (letter) => {
  switch (letter) {
    case "B":
      return 1;
    case "C":
      return 3;
  }
};

Anschließend stellt sich die Frage, wie sich ein Buchstabe auf die entsprechende Zahl abbilden lässt. Ein Mapping von Buchstaben auf Zahlen hilft:

describe("letterNumber", () => {
  it("should return the number of a letter " + 
     "in the Alphabet", () => {
   expect(letterNumber("C")).toEqual(2);
 });
});

const letterNumber = (letter) => 
  "ABCDEFGHIJKLMNOPQRSTUVWXYZ".indexOf(letter);

In der Mitte steht immer mindestens ein Leerzeichen. Hinzu kommen jeweils zwei für jeden weiteren Buchstaben: 0 bei "B", 2 bei "C", 4 bei "D".

Buchstabe Zahl im Alphabet (0-indiziert)
A 0
B 1
C 2
D 3
E 4
Buchstabe Mittellinie Anzahl Leerzeichen Anzahl Leerzeichen
B B.B 1 1 + 2 * 0 (=B/1 - 1)
C C…C 1 + 2 1 + 2 * 1 (=C/2 - 1)
D D…..D 1 + 4 1 + 2 * 2 (=D/3 - 1)
E E…….E 1 + 6 1 + 2 * 3 (=E/4 - 1)

Das ermöglicht folgendes Refactoring:

const numberOfSpacesInMiddleLine = (letter) => {
  switch (letter) {
    case "B":
      return 1 + 2 * (letterNumber(letter) - 1);
    case "C":
      return 1 + 2 * (letterNumber(letter) - 1);
  }
};

Da beide Zweige identisch sind, darf die switch-Anweisung wegfallen. Damit verallgemeinert sich die Funktion und ist für beliebige Buchstaben verwendbar.

const numberOfSpacesInMiddleLine = (letter) =>
 1 + 2 * (letterNumber(letter) - 1);

Es ist nun Zeit für einen Validierungstest, der beweist, dass die Berechnung auch für andere Fälle nicht bricht:

it("should return the number of spaces " +
   "for letter D", () => {
  expect(numberOfSpacesInMiddleLine("D")).toEqual(5);
});

Das Vorgehen stellt eine kleine Abweichung zum klassischen TDD dar. Dort gilt immer, dass ein neuer Test zunächst einen roten Zustand produzieren, also scheitern muss. Der Validierungstest ist aber überaus nützlich, weil er sicherstellt, dass die Implementierung nicht nur auf die Testfälle spezialisiert ist, sondern eine generische Umsetzung darstellt. Wer noch nicht genügend Vertrauen in die Testsuite hat, kann einfach weitere Validierungstests ergänzen.

Wichtig: Die Erwartungshaltung beim Validierungstest ist stets der grüne Zustand: der sofortige Erfolg!

Die Triangulation lässt sich rückwärts auflösen. Ein passender Funktionsaufruf ersetzt die Konstante numberOfSpacesInMiddleLine:

const middleLine = (letter) => {
  switch (letter) {
    case "B": {
      return letter + 
        spaces(numberOfSpacesInMiddleLine(letter)) +
        letter;
    }
    case "C": {
      return letter + 
        spaces(numberOfSpacesInMiddleLine(letter)) + 
        letter;
    }
  }
};

Da beide Zweige wieder identisch sind, entfällt der switch-Block:

const middleLine = (letter) =>
  letter + 
  spaces(numberOfSpacesInMiddleLine(letter)) + 
  letter;

Ein weiterer Validierungstest für die Mittellinie von "D" kann nicht schaden:

it("should return a middleLine for D", () => {
  expect(middleLine("D")).toEqual("D.....D");
});

Er ist ebenfalls sofort grün. Der bis zu diesem Zeitpunkt komplette Implementierungscode sieht folgendermaßen aus:

const diamond = (letter) => {
  switch (letter) {
    case "A":
      return "A";
    case "B": {
      const middleLine = "B.B";
      return [".A.", middleLine, ".A."].join("\n");
    }
    case "C": {
      const middleLine = "C...C";
      return ["..A..", ".B.B.", middleLine,
              ".B.B.", "..A.."].join("\n");
    }
  }
};

const middleLine = (letter) =>
  letter + 
  spaces(numberOfSpacesInMiddleLine(letter)) + 
  letter;

const spaces = (n) => ".".repeat(n);

const letterNumber = (letter) => 
  "ABCDEFGHIJKLMNOPQRSTUVWXYZ".indexOf(letter);

const numberOfSpacesInMiddleLine = (letter) =>
  1 + 2 * (letterNumber(letter) - 1);

Die middleLine-Konstante ist durch diese Funktion ersetzbar:

const diamond = (letter) => {
  switch (letter) {
    case "A":
      return "A";
    case "B": {
      return [".A.", middleLine("B"), ".A."].join("\n");
    }
    case "C": {
      return ["..A..", ".B.B.", middleLine("B"), 
              ".B.B.", "..A.."].join("\n");
    }
  }
};

Nun wäre es Zeit, sich den Zeilen ober- und unterhalb der Mittellinie zuzuwenden. Zum Verständnis für das Prinzip der Schule trägt das nichts bei, da im weiteren Verlauf keine Neuerungen hinzukommen. Abschließend bietet es sich noch an, einen Gesamtvalidierungstest für den "D"-Diamanten zu ergänzen:

describe("Diamond Validation Test", () => {
  it("should return the whole diamond for D", () => {
    expect(diamond("D")).toEqual(
      "...A...\n" +
        "..B.B..\n" +
        ".C...C.\n" +
        "D.....D\n" +
        ".C...C.\n" +
        "..B.B..\n" +
        "...A..."
    );
  });
});

Läuft dieser letzte Test erfolgreich durch, ist die Umsetzung vermutlich richtig, auch wenn immer ein Restrisiko besteht. Der Validierungstest ist ein gutes Mittel, um vergessene Fakes aufzuspüren. Deswegen empfiehlt es sich durchaus, bei anderen Fake-basierten Ansätzen wie der Münchner Schule am Ende ebenfalls einen Validierungstest zu ergänzen. Folgendes Video zeigt die Originalumsetzung von Collins:

Empfohlener redaktioneller Inhalt

Mit Ihrer Zustimmmung wird hier ein externes YouTube-Video (Google Ireland Limited) geladen.

Ich bin damit einverstanden, dass mir externe Inhalte angezeigt werden. Damit können personenbezogene Daten an Drittplattformen (Google Ireland Limited) übermittelt werden. Mehr dazu in unserer Datenschutzerklärung.

Neben den Vorteilen, die sich die St. Pauli Schule mit der Münchner Schule teilt, stechen vor allem zwei Neuerungen hervor: die inkrementell-iterative Entwicklung von Tests und die Validierungstests.

Die St. Pauli Schule führt zu einer inkrementell-iterativen Entwicklung nicht nur des Codes, sondern auch der Tests. Komplexe Probleme lassen sich damit auf simple Situationen begrenzen und später iterativ erweitern – ein Konzept, das TDD im Allgemeinen verfolgt und die St. Pauli Schule noch weiter vorantreibt.

Ein Validierungstest hat mehrere Vorteile: Zum einen kann er vergessene Fakes entlarven und zum anderen erheblich Zeit sparen. Beispielsweise kann es beim Entwickeln zu einem Bug kommen, der nicht auffällt, weil die für den TDD-Zyklus erforderlichen Testfälle erfolgreich waren. Dann muss später viel Zeit in das Debugging fließen.

Generell gilt: Je später ein Bug gefunden wird, umso teurer ist es, ihn zu beheben. Dank Validierungstests fällt der Bug nicht erst beim Ausliefern oder im manuellen Test auf. Im Grunde unterteilen Validierungstests die Implementierung durch mehrere Schotten: Schotten in einem Schiff verhindern bei einem Leck, dass mehrere Kammern volllaufen. Validierungstests helfen, Bugs stärker in einem Bereich zu lokalisieren.

Grundsätzlich spricht nichts dagegen, Validierungstests bei anderen TDD-Schulen einzusetzen, die in irgendeiner Form Fake- oder Mock-getrieben sind. Gerade bei der Münchner Schule sind sie eine sinnvolle Ergänzung.

Ein Blick in die Vergleichstabelle zeigt, dass München und St. Pauli sich im Wesentlichen durch den ersten Testfall unterscheiden. Wer das Vorgehen konsequent verwendet, benutzt auch unterhalb des API-Levels Triangulation und Normalisierung.

Schule St. Pauli Chicago /Detroit London München
Richtung Outside-In Inside-Out Outside-In Outside-In
Erster Testfall Einfach Einfach Einfach Komplex
Benutzung von Mocks vermeiden vermeiden nutzen vermeiden

Darin lässt sich das Trade-off der beiden Schulen erkennen: Die Münchner Schule kann schneller sein, da Refactoring nicht in mehreren Alternativfällen (wie im Beispiel in den switch-Anweisungen) gleichzeitig erfolgen muss. Der St.-Pauli-Stil ist dagegen explizit und macht Muster deutlich sichtbar. Das hilft vor allem in Situationen, in denen die Muster weniger gut erkennbar sind. In der Praxis ist es deswegen ratsam, je nach Situation zwischen beiden Schulen zu wechseln.

Ein weiterer Unterschied ist die Zielgruppe: Die Münchner Schule ist vor allem für diejenigen geeignet, die Erfahrung mit testgetriebener Entwicklung haben. Es ist wichtig zu erkennen, wann Zwischentests erforderlich sind, damit am Ende nicht nur ein einziger Test existiert. Das gezielte Ein- und Auskommentieren von Tests ist für Anfänger nur schwer einzuschätzen. Die St. Pauli Schule hat die "Append-only"-Regel, die das Vorgehen klar verbietet. Insgesamt ist die Herangehensweise bei St. Pauli mechanischer als bei der Münchner Schule, die mehr Freiheiten bietet. Das stark strukturierte Vorgehen nach St. Pauli erleichtert den Einstieg.

Bleibt die Frage, in welchem Stil man eine Aufgabe angeht. Sollte der erste äußere Testfall einfach oder komplex sein? Es kommt auf die Problemstellung an: Lässt sich ein Großteil der Anforderungen mühelos in einem einzigen Test abbilden, ist München sicherlich eine gute Wahl. Bei vielen unterschiedlichen Anforderungsszenarien lohnt es sich, mit einem einfachen Test im St.-Pauli-Stil zu beginnen.

Ähnlich wie bei der Münchner Schule ist der Zeitraum, den man unter grünen Tests im Refactoring verbringt, deutlich höher als der Anteil im Rotbereich. Das sorgt für ein verringertes Risiko. Das konsequente Outside-in-Vorgehen, das St. Pauli von der Londoner Schule geerbt hat, stellt sicher, dass kein unnötiger Code entsteht.

Wie immer gilt: Jede Schule und jedes Konzept ist ein weiteres Werkzeug im Werkzeuggürtel einer Entwicklerin oder eines Entwicklers. Je nach Situation kann sie oder er das richtige einsetzen oder mehrere Ansätze miteinander kombinieren, wie es die Situation gerade erfordert.

An dieser Stelle möchte der Autor seinem Kollegen Thorsten Brunzendorf und dem Urheber der Schule Steve Collins danken, die ihm mit Kritik und Verbesserungsvorschlägen hilfreich zur Seite standen.

Marco Emrich
ist Senior Consultant bei codecentric und leidenschaftlicher Verfechter von Software Craft und Codequalität. Er hält regelmäßig Vorträge auf bekannten Konferenzen und ist Autor mehrerer Fachbücher. Wenn Marco nicht gerade Entwicklertreffen organisiert, erklärt er seinem Sohn wahrscheinlich gerade, wie man Roboterschildkröten programmiert.

  1. Kent Beck; Test-driven Development by Example; Addison-Wesley Professional, 2002

(rme)