Musterlösung: 01 - Die Anforderung-Logik Lücke

Aufgabe 1 - Gründe für automatisiertes Testen

Bevor du dich einlässt auf test-first Codierung, willst du sicher sein, dass der Aufwand, den das bestimmt bedeutet, auch gerechtfertigt ist. Deshalb habe ich dich ermuntert, selbst mal zu überlegen, welche guten Gründe es geben könnte, sich darauf einzulassen.

Wie du an den folgenden Listen siehst, finde ich, dass es eine ganze Menge solcher guten Gründe dafür gibt. Ja, ich weiß, dass test-first Vorgehen manchmal nervt. Und manchmal “klappt es nicht” - doch daraus würde ich nicht ableiten, dass es nicht das Ideal ist, dem du zustreben solltest. Test-first ist für mich zum Standard avanciert, zur Norm. So sieht fachgerechte Programmierung von Produktionscode einfach heute aus.

Beispielhafte Gründe für die Testautomatisierung

Zuerst aber “nur” die Testautotmatisierung. Das ist weniger als test-first und schließt also auch test-after ein (wenn es denn “dazu kommt”). Warum sollte Software überhaupt automatisiert getestet werden, statt manuell, indem du die Software aufrufst und “durchspielst”?

  • Höhere Stabilität durch Detektion von Regressionen: Weil automatisierte Tests jederzeit “auf Knopfdruck” ausgeführt werden können, besteht kein Grund auf einen Test, der schon einmal ausgeführt wurde, zu verzichten. Die Reifetests von heute werden so zu Stabilitätstests von morgen.
  • Dokumentation der Anforderungen: Tests sind ausführbare Spezifikationen. Die Summe aller Tests beschreibt die Erwartungen an Software unzweideutig.
  • Dokumentation der Nutzung von Code: Testcode ist der erste Nutzer von Produktionscode. Wie jener diesen bedient ist beispielhaft. Mit Tests können seltene Sonderfälle und allgemeine wie umfassende Use Cases beschrieben werden.
  • Geringere Kosten - über die Zeit: Auch wenn die Einstandskosten für automatisierte Tests hoch sein mögen - einen Test jetzt zu schreiben, statt das Programm nur mal eben auszuführen für eine Reifeprüfung, macht viel mehr Arbeit -, liegt der break-even point in nicht allzu weiter Zukunft. Das gilt für Reifeprüfungen wie die noch wichtigeren Stabilitätsprüfungen.
  • Höhere “Benutzerfreundlichkeit”: Automatisierte Tests können von jedem Teammitglied oder gar automatisiert in einer CB/CI-Pipeline ausgeführt werden.
  • Nachvollziehbarkeit: Nur mit automatisierten Tests ist jederzeit nachvollziehbar, was überhaupt getestet wurde und wird.
  • Beobachbare Testabdeckung: Nur mit automatisierten Tests lässt sich die Testabdeckung beobachten und damit beurteilen, ob das Testen überhaupt schon/noch ausreichend ist.
  • Größere Testumgebungsvielfalt: Automatisierte Tests lassen sich nicht nur an den Rechnern ausführen, an denen gerade Menschen sitzen. Sie können vielmehr ausgerollt werden auf virtuelle Maschinen, die in jeder erdenklichen (und relevanten) Weise konfiguriert sind.
  • Größere Ordnung: Um automatisiert testen zu können, muss Code in geeigneter Weise strukturiert sein; Testbarkeit ist eine Voraussetzung für automatisierte Tests. Testbar ist Code, wenn eine gewisse Entkopplung von Codeteilen existiert, was die Ordnung erhöht und den Code wandelbarer macht.

Beispielhafte Gründe für test-first Testautomatisierung

Nach der obigen Liste sollte es keine zwei Meinungen mehr geben, hoffe ich, dass automatisierte Tests “alternativlos” sind. Jetzt musst du sie nur noch schreiben. Aber wann? Ich meine: konsequent test-first. Denn das hat weitere Vorteile:

  • Höhere Verlässlichkeit: Automatisierte Tests werden überhaupt geschrieben. Denn wenn automatisierte Tests nicht zuerst geschrieben werden, dann ist später die Motivation gering und die Zeit für solche “zusätzliche Arbeit” meist knapp.
  • Höhere Testbarkeit: Wer den Produktionscode vom Test her denkt, weil er den zuerst schreibt, achtet früher (oder überhaupt) darauf, dass der Produktionscode auch einfach zu testen ist, also eine hohe Ordnung hat.
  • Bessere Schnittstellen: Schnittstellen bieten Dienstleistungen an und sollten daher vom Nutzer aus gedacht werden. Wenn Entwickelnde zuerst in Tests die Schnittstellen ihres Produktionscodes nutzen müssen, bevor sie sie “in Code gießen”, sind sie sensibler dafür, was andere Nutzer später für einfach/verständlich halten könnten. Test-first Codierung ist eine Form von “eat your own dog food”.

Test-first geht über “nur” automatisierte Tests hinaus. Du schreibst die automatisierten Tests verlässlich vor dem Produktionscode. Aber wie? Test-first ist noch nicht Test-Driven Development (TDD) wie du es vielleicht kennst.

Was Gründe für TDD sein könnten, frage ich dich allerdings hier nicht. Diese Praktik ist Thema eines eigenen Kapitels. Test-first ist für mich wichtiger und grundlegender. Der Titel der beiden Bände ist für mich Programm.

Aufgabe 2 - Eine Anwendung test-first entwickeln

Analyse

Akzeptanztestfälle

Das Programm wird mehrfach gestartet und muss daher seine “Erinnerung” (Zustand) an bisherige Gäste persistieren.

Die Funktionsweise kann “von außen” überprüft werden, indem nacheinander Gäste im Rahmen desselben Szenarios begrüßt werden.

API-Funktion

Um das SUT inkl. Zustand einfach starten/stoppen zu können, wird die Begrüßungfunktion einer Instanzklasse zugeordnet:

1 class HelloBackend {
2   public string Greet(string name) {...}
3 }

Diese Funktion überspannt die gesamte Logik des Programms, außer der für die Benutzerschnittstelle. Auf diese Weise kann maximal viel Logik im Akzeptanztest überprüft werden.

Eine Konfiguration des Backend zumindest mit einem Pfad (connection string) für die Persistenz, scheint allerdings hilfreich für das Rücksetzen/Starten im Test.

Entwurf der Persistenz

Das Programm muss sich über Starts hinweg die begrüßten Gäste merken. Laut der Anforderungen ist mit ungefähr 3*100*25*2= 15.000 Besuchen zu rechnen. Die Zahl der verschiedenen Gäste ist sicher geringer.

Das ist keine sehr große Zahl, so dass die persistente Zustandshaltung sehr simpel sein kann: es genügt eine Textdatei, deren Inhalt komplett in-memory gehalten werden könnte.

Weder ein Speicherplatz- noch ein Performanceproblem würde ich erwarten.

Jeder Gast könnte mit einem Tupel (Name, Anzahl der Besuch) in der Gästeliste vertreten sein (Option Map), z.B.

1 Roger, 3
2 Janine, 2
3 Mark, 7
4 Henry, 1

Oder die Gästeliste besteht schlicht aus einer fortgeschriebenen Liste von Namen (Option Event Stream):

1 Roger
2 Janine
3 Mark
4 Roger
5 Henry
6 Mark
7 Janine

Letztere Lösung scheint mir flexibler, auch wenn sie mehr Speicherplatz benötigt. Bei der geringen Besuchszahl ist Speicherplatz jedoch kein Problem.

Codierung

1. Akzeptanztests

 1 [Fact]
 2 public void Acceptance_test_scenario()
 3 {
 4   const string TEST_DB = "test.db";
 5   File.Delete(TEST_DB);
 6 
 7   var sut = new HelloBackend(TEST_DB);
 8   sut.Greet("Roger").Should().Be("Hello, Roger!");
 9   sut.Greet("Janine").Should().Be("Hello, Janine!");
10 
11   sut = new HelloBackend(TEST_DB); // auf diese Weise wird vermieden, 
12   // dass Zustand nur in-mem gehalten wird
13   sut.Greet("Roger").Should().Be("Welcome back, Roger!");
14   sut.Greet("Roger").Should().Be("Hello my good friend, Roger!");
15 
16   sut = new HelloBackend(TEST_DB);
17   sut.Greet("Janine").Should().Be("Welcome back, Janine!");
18 
19   for (var i = 1; i <= 20; i++)
20     sut.Greet("Roger");
21 
22   sut.Greet("Roger").Should().Be("Hello my good friend, Roger!");
23   sut.Greet("Roger").Should().Be("Hello my good friend, Roger! Congrats! You are now a \
24 platinum guest!");
25   sut.Greet("Roger").Should().Be("Hello my good friend, Roger!");
26 }

Die Akzeptanzkriterien habe ich alle in einem Szenariotest (Use Case) zusammengefasst. Dadurch muss ich den Zustand des System under Test nicht für einzelne Testfälle gesondert setzen und überprüfen. Vielmehr baut sich der Zustand natürlich durch fortschreitende Nutzung auf und wird durch korrekte Ergebnisse in der weiteren Verarbeitung implizit verifiziert. Lediglich am Anfang muss der persistente Zustand durch Löschen der “Datenbank” “auf Null gesetzt” werden, um für jeden Durchlauf gleiche Ausgangsbedingungen zu schaffen.

2. Produktionscode I: System under Test (SUT)

 1 public class HelloBackend
 2 {
 3   private readonly string _dbFilePath;
 4 
 5   public HelloBackend(string dbFilePath) {
 6     _dbFilePath = dbFilePath;
 7   }
 8 
 9   public string Greet(string name) {
10     File.AppendAllLines(_dbFilePath, new[]{name});
11     var names = File.ReadAllLines(_dbFilePath);
12 
13     var n = names.Count(x => x == name);
14 
15     var greeting = n switch {
16         1 => $"Hello, {name}!",
17         2 => $"Welcome back, {name}!",
18         _ => $"Hello my good friend, {name}!"
19     };
20     if (n == 25) greeting += " Congrats! You are now a platinum guest!";
21 
22     return greeting;
23   }
24 }

Die komplette zu testende Logik ist in einer Klasse gekapselt. Eine weitere Modularisierung scheint mir angesichts des geringen Schwierigkeitsgrades, des Abstraktionsgrades der C#-Mittel und der Wahl des Persistenzformates sowie des Tests im Rahmen eines Szenarios nicht nötig.

Das SUT enthält auch die Persistenzlogik, um sicherzustellen, dass die korrekt ist. Sie nicht zu testen, wäre leichtfertig. Sie jedoch getrennt zu testen, würde ein Zusammenspiel mit der Domänenlogik ungetestet lassen.

Die Konfiguration des SUT mit dem Datenbanknamen findet über den Konstruktor statt. Damit ist sie “aus dem Weg” bei der Nutzung des Backend-Objektes. Meine Annahme dabei ist, dass während der Existenz des Backends die Datenbank nicht gewechselt werden muss.

3. Produktionscode II: Einbettung des SUT in ein Programm mit Benutzerschnittstelle

 1 class Program
 2 {
 3   static void Main(string[] args) {
 4     var backend = new HelloBackend("guests.txt"); // Konfiguration
 5 
 6     while (true) {
 7       Console.Write("Name: ");
 8       var name = Console.ReadLine();
 9       if (string.IsNullOrWhiteSpace(name)) continue;
10 
11       var greeting = backend.Greet(name); // Nutzung
12 
13       Console.WriteLine(greeting);
14     }
15   }
16 }

Um das automatisiert getestete SUT siehst du jetzt natürlich weitere Logik. Die ist nicht (einfach) automatisiert testbar. In diesem Fall ist sie allerdings trivial; bei einem abschließenden manuellen Test würdest du sofort merken, wenn darin ein Bug sitzt, denke ich.

Aber nimm solche Logik nicht auf die leichte Schulter. In einem späteren Kapitel werden wir uns damit näher beschäftigen. Solche Mischungen von Logik und einem Funktionsaufruf wie backend.Greet() in Main() sind weder gut für die Verständlichkeit noch für die Testbarkeit.