TDD-Einführung, Teil 3
04.06.2023, 19:07 Uhr
Drei gewinnt
Ein paar letzte Tests, das UI und Gedanken zur Testabdeckung.
Die testgetriebene Entwicklung unserer Version von Tic Tac Toe findet in diesem dritten Teil der Artikelserie ihren Abschluss, und Sie stellen sich dabei zugleich eine der wichtigsten Fragen, wenn es um Unit-Tests geht: Wozu das alles überhaupt? Und keine Sorge: Es ergibt absolut Sinn, dass diese Frage nicht schon im ersten Teil gestellt wurde.
Schön, dass Sie wieder mit dabei sind und sich darauf freuen, mit diesem letzten Teil der Serie (den vorangegangenen finden Sie unter [1]) Ihre Implementierung von Tic Tac Toe zu einem Ende zu bringen. Und bevor es gleich damit losgeht, behalten Sie im Hinterkopf: Jedem Ende wohnt ein neuer Anfang inne.
In diesem Sinne eine kurze Zusammenfassung dessen, worum es bisher in den ersten beiden Teilen ging.
Ihr aktueller Stand der testgetriebenen Entwicklung sollte eine Solution mit im Moment noch zwei Projekten enthalten: eine Bibliothek, welche die Spiele-Logik enthält, und ein Projekt, in dem Sie alle bisherigen Anforderungen sowohl ans Spielbrett als auch an die Benutzer-Interaktion in Form von Unit-Tests prüfen.
Es existiert bereits der rundenweise Spielerwechsel, und auch den Code zum Setzen von Feldern und Erkennen einer Gewinnerkombination haben Sie inklusive erforderlicher Tests umgesetzt. Der Clou dabei: Sie haben währenddessen gelernt, wie einfach es ist, etwas an der Implementierung zu verändern und sich über die Unit-Tests bestätigen zu lassen, dass damit die Anforderungen weiterhin erfüllt werden. Und somit geht es nun weiter mit den Gamestates!
Gamestates
Viele Wege führen nach Rom, heißt es. Das Gleiche betrifft auch den Ablauf innerhalb von Spielen – seien sie auch noch so einfach. Hier ein Beispiel, wie Tic Tac Toe ablaufen könnte:
Begonnen wird im Status Running. Die Spiele-Logik wird so oft angewandt, wie es einen Status ungleich null gibt. Im Status Running findet das eigentliche Spiel statt; die Spieler setzen abwechselnd und es wird geprüft, ob ein Gewinner oder ein Unentschieden vorliegt. Hat ein Spieler das Spiel gewonnen, geht der Status über in Won, kommt es zu einem Unentschieden, geht der Status über in Remis. Den Abschluss bildet der Rückgabewert null, sodass das Spiel in der Hauptschleife beendet wird.
Dieser Ablauf ist so simpel, dass es fast nicht lohnt, ihn aufzuschreiben. Oder nicht? Es könnte in jedem Fall genügen, ihn in Form weniger verschachtelter if-then-else-Bedingungen zu implementieren. Da Sie allerdings gerade einen testgetriebenen Ansatz verfolgen und auch verstanden haben, dass es immer eine gute Idee ist, ein System so zu implementieren, dass es offen gegenüber Erweiterungen, aber geschlossen gegenüber Veränderungen ist, kann das Strategie-Muster an dieser Stelle eine geeignete Wahl sein, um die Gamestates umzusetzen.
Daher zunächst ein Testfall: Ergänzen Sie das Unit-Test-Projekt um eine Klasse GameStateTests und schreiben Sie einen ersten Testfall namens GameState_Running. Hier ein Beispiel:
[TestClass]
public class GameStateTests
{
[TestMethod]
[TestCategory("GameState")]
public void GameState_Running()
{
// Act
var expectedState = typeof(GameStateRunning);
var actualState =
new GameStateRunning().Execute();
// Assert
Assert.IsInstanceOfType(
actualState,
expectedState);
}
}
Hier geht es nun erst einmal darum, einen Einstieg zu finden. Es sollte einen Status GameStateRunning geben, der sich ausführen lässt, und wenn sonst nichts weiter passiert, wäre die Idee, dass sich der Status auch nicht weiter verändert.
Übrigens: Im Folgenden finden Sie öfter klassische Assertion-Statements in den Unit-Tests vor. Wenn Sie mögen, probieren Sie doch einfach mal, wie sich diese mit der Bibliothek FluentAssertions umsetzen lassen, und entscheiden selbst, welcher Stil Ihnen mehr liegt und was Ihnen leichter fällt.
Doch zurück zum Text: Zwei Smart-Tags weiter existiert die neue Klasse im Projekt TicTacToe.Lib und sieht in etwa wie folgt aus:
namespace TicTacToe.Lib;
public class GameStateRunning
{
public object Execute()
{
throw new NotImplementedException();
}
}
Einmal ausgeführt, läuft der Test wie erwartet ins Leere, und da die beiden Variablen expectedState und actualState im Test mittels var deklariert wurden, hat Visual Studio auch keinen besonders schlauen Code generiert.
Wenden Sie sich also dem Rückgabewert zu. Das Ziel ist es, dass das zu implementierende Strategiemuster in der Lage ist, verschiedene Status zu verarbeiten. Sie sollten also alle einer ähnlichen Implementierung folgen. Das schreit zunächst einmal nach einer Schnittstelle. Hier eine Möglichkeit:
namespace TicTacToe.Lib;
public interface IGameState
{
IGameState Execute();
}
Dann noch eine kleine Anpassung innerhalb der Klasse GameStateRunning:
public class GameStateRunning : IGameState
{
public IGameState Execute() {
return this;
}
}
Und schon ist der Testfall grün. Passieren tut darin allerdings auch noch nicht sehr viel, und neben dem tatsächlichen Workflow des Spiels fehlt auch die komplette Interaktion mit den Spielern. Beschäftigen Sie sich zunächst einmal mit der Integration des Spielbretts: Damit sich während des GameStateRunning etwas tun kann, muss dieser Status das Gameboard bedienen, also ermitteln, welches Feld besetzt werden soll, die Züge zählen, prüfen, ob jemand gewonnen hat, et cetera. Eine Möglichkeit dazu wäre es, das Gameboard zum Beispiel der Execute-Methode zu übergeben. Erweitern Sie zunächst also das Interface:
IGameState Execute(GameBoard board);
Und bevor das Spielbrett in der Lage ist, Eingaben vom Spieler entgegenzunehmen, müssen Sie auch dieses entsprechend erweitern. Ergänzen Sie die Klasse GameBoard ganz am Ende um eine private Member-Variable:
IUserInput userInput;
Und fügen Sie dann noch einen Konstruktor hinzu:
public GameBoard(IUserInput userInput)
{
this.userInput = userInput;
}
Last, but not least benötigt das Spielbrett noch eine Methode, um Spielereingaben auch entgegennehmen zu können:
public int GetField()
{
return userInput.GetField(this.ActivePlayer);
}
Dabei kann der Methode GetField aus dem Interface IUserInput dann auch der aktive Spieler übergeben werden – den das Spielbrett ja kennt.
Das ermöglicht es nun dem GameStateRunning, das Feld zu verarbeiten, für das sich ein Spieler entscheidet, dieses auf dem Spielbrett zu besetzen und auch die Prüfung vorzunehmen, ob jemand gewonnen hat. Es fügen sich nun also alle Puzzleteile zusammen.
Sie erinnern sich noch an den Unit-Test, von dem die letzten Änderungen motiviert waren? Hier ist jetzt noch eine Anpassung erforderlich, bevor Sie weitermachen. Ergänzen Sie im Unit-Test GameState_Running den Code, um eine Spielereingabe zu simulieren, und übergeben Sie der Execute-Methode ein neues GameBoard mit eben jener Simulation:
[TestMethod]
[TestCategory("GameState")]
public void GameState_Running()
{
// Arrange
var userInput = new StubUserInput()
{
GetFieldPlayer = player => { return 1; }
};
// Act
var expectedState = typeof(GameStateRunning);
var actualState = new GameStateRunning()
.Execute(new GameBoard(userInput));
// Assert
Assert.IsInstanceOfType(
actualState,
expectedState);
}
Falls Sie den Code nun zu übersetzen versuchen, kann es sein, dass noch der eine oder andere Fehler auftritt. Beheben Sie diese gerne selbstständig. Was beispielsweise die Gameboard-Tests betrifft: Hier benötigen Sie keine Spielereingabe und können das Gameboard ohne sie initialisieren (null).
Abschließend für diesen Abschnitt zeigt Listing 1 die vollständige Implementierung der Execute-Methode aus GameStateRunning. Nutzen Sie auch die Gelegenheit, den Code an dieser Stelle einmal schrittweise zu debuggen. Als Ausgangspunkt nehmen Sie den Test GameState_Running. Es ist nämlich auch einfach mal nett zu sehen, wie alles ineinandergreift.
Willkommen in der Matrix
Für die nächsten Tests zeigt sich, wie gut die bisher eingesetzte Strategie ist. Denn um automatisiert zu prüfen, ob ein Spielverlauf korrekt in einem Sieg oder einem Unentschieden endet, muss ein solcher simuliert werden können. Ist der bisherige Code dazu in der Lage, und falls ja, wie?
Bisher existiert noch kein richtiges Spiel. Es gibt nicht einmal ein Konsolenprojekt oder Ähnliches. Dennoch existieren (fast) alle Komponenten, die für das Spiel erforderlich sind. Um nun zumindest Tests schreiben zu können, die den späteren Workflow prüfen, braucht es eine Möglichkeit, den Spielverlauf zu simulieren.
Schreiben Sie daher doch einfach eine Methode, die das tut. Dazu braucht es eigentlich nur eine Schleife, die das Spiel so lange ausführt, bis der Status null zurückgegeben wird. Aber eines nach dem anderen. Ergänzen Sie die Klasse GameStateTests um eine neue Methode:
IGameState SimulateGame(GameBoard board)
{
IGameState currentState = new GameStateRunning();
IGameState lastState = null;
while (currentState != null)
{
lastState = currentState;
currentState = currentState.Execute(board);
}
return lastState;
}
Nun überlegen Sie sich eine Zugreihenfolge, die dazu führt, dass ein Spieler gewinnt. Hier ein Beispiel:
private int[] movesPlayerOneWins = { 1, 2, 4, 5, 7 };
X O _
X O _
X _ _ => Spieler 1 gewinnt
Damit die Simulation funktioniert, müssen Sie sich noch den aktuellen Zug merken und diesen dann während des Spielverlaufs hochzählen.
Ergänzen Sie die Klasse GameStateTests sowohl mit dem Array von oben als auch durch folgende Zeile:
static int move = 0;
Jetzt benötigen Sie nur noch einen Test:
[TestMethod]
[TestCategory("GameState")]
public void GameState_Won()
{
// Arrange
var userInput = new StubUserInput()
{
GetFieldPlayer = player => {
return movesPlayerOneWins[move++]; }
};
// Act
var expectedState = typeof(GameStateWon);
var actualState = SimulateGame(
new GameBoard(userInput));
// Assert
Assert.IsInstanceOfType(
actualState,
expectedState);
}
Hier die Idee: Sie erzeugen einen Delegaten, über den die Spielereingabe realisiert wird. Damit initialisieren Sie das GameBoard und übergeben es der Simulation. Diese wird dann so lange die Execute-Methode aufrufen, bis der finale Status null eintritt. Dazu ruft der Status GameState_Running immer wieder die GetField-Methode auf, die wiederum aus dem GameBoard heraus Ihre Attrappe bemüht, um sich aus dem vorgegebenen Array die vorgegebenen Züge zu holen.
Der Test schaut gut aus; was fehlt, um ihn zu erfüllen, ist der Status GameState_Won.
Machen Sie es sich für den Moment einfach und beenden dabei zunächst einmal nur das Spiel. Fügen Sie dazu dem Projekt TicTacToe.Lib eine neue Klasse GameStateWon hinzu:
public class GameStateWon : IGameState
{
public IGameState Execute(GameBoard board) {
return null;
}
}
Und nun wechseln Sie zur Klasse GameStateRunning und sorgen dafür, dass im Fall eines Sieges der korrekte nächste State getriggert wird:
if (board.CheckWinner()) {
return new GameStateWon();
}
Führen Sie den Test GameState_Won nun einmal aus, und er sollte grün sein. Falls nicht, verwenden Sie den Debugger, um zu ermitteln, an welcher Stelle es zu einem Problem kommt.
Das Remis
Jetzt bleibt nur noch das Unentschieden, und ich bin sicher, dass Sie den erforderlichen Code nun vollkommen selbstständig ergänzen können. Andererseits können Sie auch einfach weiterlesen. Freilich nur, um Ihren Code mit dem abzugleichen, was in den folgenden Zeilen steht.
Am Anfang steht eine Reihe von Zügen, die in einem Unentschieden enden:
int[] movesRemis = { 1, 2, 4, 7, 8, 5, 9, 6, 3 };
Gefolgt von einer dazu passenden Testmethode:
[TestMethod]
[TestCategory("GameState")]
public void GameState_Remis()
{
// Arrange
var userInput = new StubUserInput()
{
GetFieldPlayer = player => {
return movesRemis[move++]; }
};
// Act
var expectedState = typeof(GameStateRemis);
var actualState = this.SimulateGame(
new GameBoard(userInput));
// Assert
Assert.IsInstanceOfType(actualState, expectedState);
}
Um den Testfall zu bestehen, wird die Klasse GameStateRemis im Lib-Projekt ergänzt:
public class GameStateRemis : IGameState
{
public IGameState Execute(GameBoard board) {
return null;
}
}
Und abschließend im Code zu GameStateRunning.Execute aufgerufen:
...
else if( moveCount == 9)
{
return new GameStateRemis();
}
Obwohl weiter vorne in diesem Artikel nicht näher darauf eingegangen wurde, ist Ihnen klar, dass es sich um ein Unentschieden handeln muss, wenn nach neun Zügen noch immer kein Gewinner feststeht. Das war es dann auch schon und der Testfall sollte grün sein. Sieht auch Ihr Code in etwa so aus?
Wasserdicht?
Jetzt einmal abgesehen davon, dass das Spiel noch immer nicht spielbar ist, dafür aber immerhin (fast) alle notwendigen Funktionen enthalten sind und diese auch getestet werden: Sind die Testfälle wasserdicht?
Falls Sie es noch nicht getan haben, führen Sie einmal alle Tests automatisiert auf einen Rutsch aus. Klicken Sie dazu im Test-Explorer beispielsweise auf Run all Tests in View. Schlägt bei Ihnen mindestens ein Test fehl? Falls ja: Was passiert, wenn Sie diesen Testfall direkt (und einzeln) ausführen? Wie erklären Sie sich das?
Worum es an dieser Stelle geht, ist klar: Einen Unit-Test zu schreiben allein stellt noch nicht sicher, dass alles reibungslos funktioniert. In diesem Fall stellt der statische Counter in der Klasse GameStateTests ein Problem dar. Denn er wird zwischen den Tests nicht zurückgesetzt.
Einmal erkannt, lässt sich dieses Problem sehr leicht lösen. Ergänzen Sie die Klasse einfach um eine Methode, die sie mit [TestInitialize] attribuieren:
[TestInitialize]
public void Initialize() {
move = 0;
}
Diese Methode wird vor jedem Tests ausgeführt und stellt sicher, dass der Counter jedes Mal mit 0 beginnt.
User Interface
Damit das Spiel auch gespielt werden kann, benötigt es eine Benutzeroberfläche. Egal wie viel Sie bis hierhin gelernt haben, es wäre verständlich, wenn Sie nun auch eine Runde Tic Tac Toe spielen wollten. Und um die Sache zunächst einfach zu machen, empfiehlt es sich, eine Konsolenanwendung dafür zu schreiben.
Beginnen Sie also damit, der Solution ein neues C#-Konsolenprojekt hinzuzufügen. Und falls Sie nun gedacht haben, dass es direkt damit losgeht, eine Schleife zu programmieren, die ähnlich der Simulation das Spiel ablaufen lässt: Haben Sie noch ein klein wenig Geduld. Denn es fehlen nur noch zwei Kleinigkeiten.
Zum einen existiert noch keine Implementierung der Schnittstelle IUserInput, die eine tatsächliche Eingabe entgegennimmt, und zum anderen fehlt auch noch eine Ausgabe des Spielfelds. Fangen Sie mit Letzterem an.
Um das Spielbrett verständlich im Textmodus darzustellen, bedarf es nicht viel und ein Vorschlag dafür kann so aussehen:
X _ _
O X _
O O X
Wie Sie sehen können, wird Spieler 1 mit einem X repräsentiert, Spieler 2 mit einem O. Leere Felder werden mit einem Unterstrich dargestellt.
Ahnen Sie es bereits? Sie schreiben dafür als Erstes einen Unit-Test! Ergänzen Sie die Klasse GameBoardTests um einen Test mit dem Namen Display_Board, setzen Sie darin ein paar Felder, erzeugen Sie eine Textausgabe des Spielbretts und prüfen Sie, ob das Ergebnis Ihrer Erwartung entspricht. Die Idee wäre hier, dass das Spielbrett der Einfachheit halber über einen ToString-Override verfügt, der die korrekte Ausgabe erzeugt.
[TestMethod]
[TestCategory("GameBoard")]
public void Display_Board()
{
// Gameboard representation
// 8 7 6
// 5 4 3
// 2 1 0
board.Set(8);
board.Set(5);
board.Set(4);
board.Set(2);
board.Set(0);
board.Set(1);
// X # #
// O X #
// O O X
var actual = board.ToString();
var expected = "X__\r\nOX_\r\nOOX\r\n";
actual.Should().Be(expected);
}
Wenn Sie mögen, versuchen Sie sich jetzt daran, diese Anforderung zu erfüllen, bevor Sie weiterlesen.
Eine mögliche und sehr simpel strukturierte Lösung könnte so aussehen:
public override string ToString()
{
// Convert the board (represented as a number)
// to a bit string. The padding makes sure that
// it is 18 characters in size and gets leading
// zeros.
var bits =
Convert.ToString(board, 2).PadLeft(18, '0');
var sb = new StringBuilder();
string nextCharacter;
for (int i = 0; i < 9; i++)
{
nextCharacter = "_"; // An empty field
if (bits[i].Equals('1')) nextCharacter = "O";
if (bits[i + 9].Equals('1')) nextCharacter = "X";
sb.Append(nextCharacter);
// Linebreak
if ((i + 1) % 3 == 0) sb.AppendLine();
}
return sb.ToString();
}
Damit sollte der Test erfüllt sein und Sie können sich der Frage nach einer Implementierung der Schnittstelle IUserInput widmen.
Wie bereits weiter oben in diesem Artikel überlegt, ergibt es Sinn, die Klasse UserInput im Konsolenprojekt unterzubringen, da sie eine Abhängigkeit zur Konsole besitzt und im Kontext der Spielerinteraktion nur hier eingesetzt werden sollte.
Fügen Sie Ihrem Konsolenprojekt also eine Klasse UserInput hinzu, die die Schnittstelle IUserInput implementiert, eine Ausgabe erzeugt und eine Eingabe erwartet. Hier ein Beispiel:
using TicTacToe.Lib;
namespace TicTacToe.Console;
public class UserInput : IUserInput
{
public int GetField(Player player)
{
string? input;
int field;
do
{
System.Console.WriteLine("Player {0},
please enter a number between 1 and 9.",
(int)player + 1);
input = System.Console.ReadLine();
} while (!int.TryParse(input, out field) ||
field < 1 || field > 9);
return field;
}
}
All Good Things
Nun endlich können Sie den Gameloop implementieren. Öffnen Sie hierzu die Datei Program.cs in Ihrem Konsolenprojekt und ersetzen Sie den Standardcode durch folgende Zeilen:
using TicTacToe.Lib;
var board = new GameBoard(new UserInput());
IGameState currentState = new GameStateRunning();
while (currentState != null) {
Console.Clear();
Console.WriteLine(board);
Try {
currentState = currentState.Execute(board);
}
catch (Exception ex) {
Console.WriteLine(ex.Message);
Console.WriteLine("Press any key to continue.");
Console.ReadLine();
}
}
Die Ausgabe des Spiels ist zwar sehr simpel, aber vollständig, siehe Bild 1. Mehr ist es wirklich nicht. Sie erzeugen zu Beginn ein Objekt, das sich um die Spielereingaben kümmert, und übergeben dieses einem neuen GameBoard.
Dieses Board übergeben Sie dem GameStateRunning und lassen das Spiel dann so lange in einer Schleife laufen, bis der Gamestate null zurückgibt. Etwaige Fehlermeldungen werden bis zum UI durchgereicht und hier sauber dargestellt. Probieren Sie beispielsweise einmal, ein Feld zu besetzen, das bereits besetzt ist.
Optimierungsmöglichkeiten
Obwohl das Spiel technisch gesehen komplett ist und die Regeln korrekt implementiert sind, wird Ihnen aufgefallen sein, dass das Spielen selbst noch wenig Spaß macht. Auf Anhieb fallen Ihnen bestimmt viele Möglichkeiten ein, das Spielerlebnis zu verbessern. Hier ein paar Ideen:
Beispielsweise wäre es nett, wenn am Ende des Spiels noch eine Ausgabe erfolgen würde, die anzeigt, wer gewonnen hat oder ob es ein Unentschieden gab. Eine Nachfrage, ob die Spieler noch einmal spielen wollen, wäre auch schön, und vielleicht haben Sie schon über einen Highscore nachgedacht.
In jedem Fall ließe sich auch die Ausgabe – selbst im Textmodus – noch interessanter gestalten. Und bei dieser Art der Darstellung könnten noch unbesetzte Felder eventuell nicht mit einem Unterstrich sondern durch die Zahl repräsentiert werden, über die die Spieler das Feld besetzen können.
Besonders interessant wird es, sobald Sie sich Gedanken über ein grafisches User Interface machen: Gelingt es Ihnen, die „Businesslogik“ eins zu eins zu verwenden und so Ihre Test-Infrastruktur weiter zu nutzen? Oder ergibt sich die Notwendigkeit einer oder mehrerer Anpassungen? Helfen Ihnen die Tests dabei, oder stehen sie Ihnen im Weg?
Meiner Erfahrung nach wäre Letzteres ein Indiz dafür, dass Sie noch nicht ganz sattelfest sind, was das Thema der testgetriebenen Entwicklung betrifft – und da wären Sie nicht allein. Denn wie Sie in dieser Einführung gemerkt haben, bedeutet diese Methode, dass Sie gedanklich anders an die Entwicklung einer Anwendung herangehen müssen, als Sie es vermutlich gewohnt sind. Das bedeutet zunächst einmal eine Umstellung.
Außerdem muss diese Methode nicht notwendigerweise immer die sinnvollste sein. Sobald Sie sich aber näher damit beschäftigt haben, fällt es Ihnen in Zukunft leichter, ein Gefühl dafür zu entwickeln, wann TDD für Sie Sinn ergibt und wann nicht.
Code Coverage
Ein Thema, das bisher noch nicht adressiert wurde, ist die sogenannte Testabdeckung – auf Englisch Code Coverage. Softwareentwickler, die es gewohnt sind, Unit-Tests zu schreiben, wissen: Grundsätzlich ist es eine gute Idee, einen möglichst großen Teil der Geschäftslogik automatisiert zu testen. Das hat unter anderem etwas damit zu tun, dass oft mehrere Entwickler an einem Projekt arbeiten und Teile der jeweiligen Komponenten Klassen und Methoden anderer Entwickler nutzen. So entstehen Abhängigkeiten, die sich bei der lokalen Arbeit an einer Version meist unkritisch verhalten. Änderungen, die in entfernten Repositories stattfinden, können aber zu Integrationsfehlern führen.
Das ist einer der Gründe, warum es zentrale Build-Systeme gibt, die in der Regel auch automatisiert alle Unit-Tests ausführen und zuweilen auch Quellcode-Änderungen schlicht ablehnen, wenn ihre Integration zu Fehlern führt. Solche Systeme funktionieren aber nur wirklich gut, wenn es eine sinnvolle Testabdeckung gibt.
Wie immer ist das entscheidende Stichwort „sinnvoll“. Entwickler, die eine einhundertprozentige Testabdeckung zu erreichen versuchen, schreiben Tests oft der Tests halber und nicht, weil sie dem Projekt einen Mehrwert bieten. Trotzdem gilt es einen Blick darauf zu haben, wie es mit der Code Coverage aussieht.
Die schlechte Nachricht vorweg: Visual Studio verfügt zwar über ein eigenes Code-Coverage-Feature, dieses ist aber nur in der Enterprise-Version enthalten.
Die gute Nachricht: Es gibt eine kostenlose Erweiterung, die diese Lücke schließt. Zu finden ist sie unter dem Namen Fine Code Coverage wie üblich im Visual Studio Marketplace [2].
Nachdem Sie die Erweiterung installiert und die Tests noch einmal durchgeführt haben, erhalten Sie eine Übersicht darüber, wie umfangreich Ihr Code inzwischen durch Tests abgedeckt ist (Bild 2). Die Ausgabe finden Sie im Menü unter View | Other Windows | Fine Code Coverage.
Einhundert Prozent
Falls Sie bis hierhin allem folgen konnten, ist die Wahrscheinlichkeit groß, dass auch bei Ihnen die Testabdeckung nicht (ganz) einhundert Prozent erreicht. Und ohne dem letzten Abschnitt zu widersprechen, nutzen Sie einfach einmal die Gelegenheit, herauszufinden, warum das so ist.
Bei genauer Betrachtung der Ausgabe fällt Ihnen auf, dass die Line Coverage überall bei einhundert Prozent liegt. Es gibt aber eine Abweichung in der Branch Coverage. Genauer: In der Klasse GameBoard scheint eine einzige Zeile nicht durch einen Test abgedeckt zu sein. Wenn Sie die Klasse öffnen, können Sie im Editor links anhand farblicher Markierungen erkennen, welche Zeilen durch Tests abgedeckt sind – und welche nicht. Scrollen Sie etwas herunter, so finden Sie den Grund in der Methode CheckWinner. Überlegen Sie kurz, was die Ursache dafür ist, und lesen Sie dann weiter.
Augenscheinlich führen hier zwei Möglichkeiten dazu, dass ein Gewinner zurückgemeldet wird. Offensichtlich. Denn entweder Spieler 1 hat gewonnen (Sie erinnern sich, dass dieser die ersten neun Felder in der GameBoard-Variablen belegt) oder Spieler 2 (der die nächsten neun Felder belegt).
Der Unit-Test GameState_Won simuliert allerdings ganz klar ein Spiel, bei dem Spieler 1 gewinnt. Die Zugreihenfolge ist sogar entsprechend benannt. Das bedeutet, dass die Bedingung ((board & winningPattern << 9) == winningPattern << 9) niemals in den Zweig hineinführt. Wollen Sie also auch die Branch Coverage auf einhundert Prozent anheben, müssten Sie eine Zugreihenfolge hinterlegen, die auch Spieler 2 gewinnen lässt, und entsprechend einen weiteren Unit-Test hinterlegen, der dieses Spiel simuliert.
Einmal von Spieler 2 abgesehen: Haben Sie dadurch etwas gewonnen? Nein. Denn dass das Bit-Shifting funktioniert, ist bereits an anderer Stelle sichergestellt. Aber Sie verlieren auch nichts. Entscheiden Sie selbst, ob ein weiterer Unit-Test an dieser Stelle sinnvoll ist.
Bewerbertests
Bevor diese Einführung zu einem Fazit gelangt, noch ein paar zusätzliche Ideen:
Entscheiden Sie über ein Team in Ihrem Unternehmen? Bewerten Sie die Qualität eingehender Bewerbungen oder konzipieren Sie sogar Testaufgaben zu diesem Zweck? Dann bietet sich dieses Beispiel hervorragend an, um auf verschiedenen Ebenen die vorhandene Kompetenz von Bewerbern zu prüfen.
Das ursprüngliche Repository zu diesem Beispiel enthält Unit-Tests, die mithilfe des Microsoft Fakes Isolation Frameworks entwickelt wurden. Nicht jeder hat Zugang zu diesem Feature, wie weiter oben bereits beschrieben. Außerdem enthält eine der Zugfolgen für die Simulation eines Spiels einen Zahlendreher, was zu Problemen führt. Der Bug mit dem statischen Zug-Zähler ist darüber hinaus ebenfalls enthalten. Die GameState-Klassen befinden sich im Konsolenprojekt und nicht in der Bibliothek, und die Ausgaben auf der Konsole sind Bestandteil der Spielelogik. Insbesondere Letzteres ist extrem problematisch, da der Aufruf von System.Console.WriteLine ab einer bestimmten Version von Visual Studio während eines Unit-Tests in einer Exception resultiert. Um das Problem zu umgehen, enthält das Bewerber-Repository einen Parameter, der innerhalb der Spielelogik zwischen einem echten und einem simulierten Spiel unterscheidet. Das aber ist in etwa vergleichbar mit dem #dieselgate … denken Sie einmal darüber nach.
Daraus ergeben sich diverse Möglichkeiten, nur zu prüfen, wie intensiv sich eine Bewerberin oder ein Bewerber mit der Solution auseinandersetzt, sowie entsprechende Ergebnisse nebeneinanderzulegen und zu vergleichen. Hier die Themen, die ich in der Auswertung der zurückgeschickten Antworten in der Vergangenheit typischerweise geprüft habe.
Unit Testing:
- Wurde die Ursache für den fehlgeschlagenen Unit-Test erkannt?
- Wurde erkannt, dass der statische Zugzähler zu Folgefehlern führt?
Mocking-Framework:
- Wurde MS Fakes mit einer Enterprise Edition weiter genutzt (lizenziert, oder via Trial-Version)?
- Wurde MS Fakes durch ein alternatives Mocking-Framework ersetzt?
- Wurde ein eigenes Mock-Objekt implementiert?
- Falls ja: Nutzt die eigene Lösung Delegates/Lambda-Ausdrücke?
Design-Analyse:
- Wurden die Gamestates in die Bibliothek verschoben?
- Wurde erkannt, dass sich das System under Test (SuT) über die Simulation im Klaren ist?
- Wurden Benutzerinteraktion und Visualisierung voneinander getrennt?
- Falls ja: Kam dabei ein erkennbares Muster zum Einsatz?
- Falls ja: Wurde dabei auch der Simulationsparameter entfernt?
Grafisches UI:
- Nutzt das grafische UI die gleiche Bibliothek wie die Konsolenanwendung?
- Konnte die Spielelogik in der Bibliothek verbleiben?
- Kommt bei der Umsetzung eines grafischen UI ein erkennbares Muster zum Einsatz?
Falls diese Idee für Sie interessant ist, finden Sie eine Excel-Datei als Beispiel für einen solchen Auswertungsbogen als Download auf unserer Website unter [3].
Pair Programming
Das Konzept des Pair Programming ist Ihnen sicher bekannt. In wenigen Worten zusammengefasst lässt es sich in etwa so beschreiben: Zwei Menschen sitzen gemeinsam an einem Computer und wechseln sich nach festen Zeitblöcken beim Programmieren ab. Mehr zum Nutzen dieser Methode finden Sie unter anderem unter [4].
Ich habe dazu mein Team in Paare aufgeteilt und jedem Paar die Aufgabe gegeben, das Spiel Tic Tac Toe zu entwickeln. Test Driven Development war dabei keine Vorgabe, und tatsächlich ist auch kein Paar auf die Idee gekommen, sich Gedanken über Tests zu machen. Insofern TDD allerdings ein bekanntes Konzept ist, wäre es interessant, die Aufgabenstellung um diese Anforderung zu ergänzen.
Die Paare haben zudem ihre Arbeit in Form von Videos aufgezeichnet, sodass alle Teams im Anschluss schauen konnten, wie jedes der Paare ganz konkret an die Aufgabe herangegangen ist.
Sollten Sie ebenfalls planen, Tic Tac Toe oder ein ähnlich simples Spielprinzip nutzen wollen, um in Ihrem Umfeld mit Pair Programming zu experimentieren, hier ein wichtiger Hinweis: Es geht um den Spaß dabei, etwas möglicherweise Neues auszuprobieren, und nicht notwendigerweise um das Ergebnis.
Fazit
Bis zu einem gewissen Punkt hat dieses Beispiel gezeigt, dass es spannend und auch interessant ist, die Perspektive zu ändern und die Umsetzung von Anforderungen einmal an die zweite Stelle zu setzen. Auch kann es Spaß machen, zuzusehen, wie nach und nach alle Komponenten wie Puzzleteile an die richtige Stelle rücken und sich so auf einmal ein Gesamtbild ergibt. Zudem sind Veränderungen am Code entspannter durchführbar, sobald Unit-Tests im Hintergrund sicherstellen, dass das Gesamtsystem dadurch nicht auseinanderfällt.
Damit testgetriebene Entwicklung aber funktioniert, ist auch klar, wie wichtig es ist, in sehr kleinen Einheiten zu denken und große Anforderungen entsprechend in überschaubare Komponenten zu zerteilen. Nur so ist es möglich, sie automatisiert zu testen, weitestgehend unabhängig voneinander und von dritten Systemen.
Auch verändert sich durch die testgetriebene Entwicklung sichtbar der Einsatz verschiedener Programmier-Paradigmen. Interfaces gewinnen noch mehr an Bedeutung, und die Notwendigkeit zu erkennen, was genau Sie testen (und wie Sie in der Lage sind, etwaige Abhängigkeiten aufzulösen), tritt in den Vordergrund.
Dass zudem das Tooling eine große Bedeutung hat, hat sich ebenfalls gezeigt. Die Verfügbarkeit verschiedener Testing-Frameworks, Visual-Studio-Erweiterungen und smarter Build-Systeme macht es leicht, sich eine individuelle Umgebung aufzubauen, die Sie darin unterstützt, noch besseren Code zu schreiben.
Ich hoffe, diese Einführung in die testgetriebene Entwicklung hat Ihnen Spaß gemacht. Wenn Sie mögen, geben Sie mir gerne Feedback. Wie Sie mich erreichen können, steht in der Infobox.
P.S.: Wussten Sie, dass es Varianten von Tic Tac Toe gibt, bei denen die Spieler unterschiedlich große Spielfiguren nutzen können, um bereits besetzte Felder zu „schlucken“? Ich sag ja nur …
Fußnoten