Fangen wir also an.
Ich bin in dieses Projekt gestartet um mich praktisch mit den, von mir beruflich stets geforderten, Disziplinen des Test-Driven-Designs sowie Continuous Testing auseinander zu setzen.
Dazu sollte nicht einfach eine Demo aus den Tiefen des Internets genügen. Ich wollte mir selber die Hände schmutzig machen, selber erfahren wie durch frühzeitiges Testen und Qualitätssicherung ein “fehlerfreies” Softwareprodukt entstehen kann.

Mein Vorgehen

Mir ist durchaus bewusst, dass das, in der Literatur beschriebene, Vorgehen von Test-Driven-Design praktisch schwer umsetzbar ist.
Das liegt meines Erachten an zwei Problemen:

  1. Wir wollen Ergebnisse sehen, und zwar schnell.
    Initiales Schreiben von Unit-Tests und die anschließende Programmierung liegt, meiner Meinung nach, nicht in der Natur eines Programmierers.
    Dennoch wollen wir die Ergebnisse unsere Arbeit qualitativ Absichern.
  2. Die eigentliche Klassenarchitektur, vor allem in derartig “agilen” Projekten wie ich es hier umgesetzt habe, steht am Anfang oft gar nicht vollständig fest.
    Statt dessen (und hier ist die Wechselwirkung mit Punkt 1) werden oft initiale Umsetzungen geschaffen um danach per Red-Green-Refactor Prozess die Klasse/Komponente zu vervollständigen.

Aufgrund dessen habe auch ich eher den Ansatz gewählt, zuerst eine grundlegende Hülle meiner Klassen zu beschreiben, um anschließend die Tests, welche die Logik innerhalb der Unit sicherstellen, zu schreiben.
Bisher bin ich damit ganz gut voran gekommen.

Die Spielregeln

Aber was genau kann man, in einem einfachen Spiel wie Tic-Tac-Toe überhaupt testen?
Ganz einfach: Wir testen ob die programmierte Lösung die Spielregeln einhält - und davon gibt es doch erstaunlich viele.
Insgesamt sind für die KI 64 Testfälle entstanden. Lediglich 9 Testfälle sind für dem eigentlichen KI-Kern zuzuordnen. Die restlichen 55 betreffen die Spielregeln (einige davon sind fachlich allerdings redundant).
Welche Spielregeln gibt es also?

  • Das Spiel wird zwischen 2 Spielern ausgetragen
  • Ein Spieler darf in seinem Zug, sein Token auf ein beliebiges freies Feld innerhalb eines 3x3 Grids setzen
  • Ein Feld gilt als frei, wenn in keinem der vorherigen Züge einer der Spieler sein Token auf dieses Feld gesetzt hat
  • Ein Spieler gewinnt, wenn seine Token eine Reihe ununterbrochene Reihe bilden (horizontal, vertikal oder diagonal)

Heute werden wir uns allerdings nur eine dieser Regeln genauer anschauen um umsetzen: Ein Spieler darf in seinem Zug, sein Token auf ein beliebiges freies Feld innerhalb eines 3x3 Grids setzen

Das Zug-Objekt

Die oben beschriebene Regel wird in meiner Lösung durch ein eigenes Objekt beschrieben.
Dies hat mehrere Gründe: Zum einen ist es dadurch einfacher der KI später eine Liste aller Züge zu zu übergeben, welche durch den Lern-Algorithmus positiv verstärkt bzw. negativ geschwächt werden sollen. Zum anderen ist es so recht einfach die notwendigen Test dem Objekt zuzuordnen (Single-Responsibility Principle).
Unglücklicherweise kennt GoLang das Konzept von Klassen nicht, erlaubt es aber durch Pakete und selbstdefinierte Datentypen (sog. structs) eine ähnliche Logik zu schaffen.

turns.go

Um das grundlegende Logik-Gerüst zu implementieren, legen wir zuerst die Datei turns.go im Verzeichnis components an.

package components

type Turn struct {
	X int
	Y int
}

type Plays struct {
	playerTurns []*Turn
}

func NewGame() *Plays {
	return &Plays{}
}

func (playerGame *Plays) NewTurn(x int, y int) (*Plays, error) {
	return nil, nil
}

Die Datei definiert die wichtigen und notwendigen Eigenschaften des Zug Objekts.
Ein Zug besteht aus der Information auf welches Feld das Token gesetzt wurde - dargestellt durch die zwei Eigenschaften X und Y im Datentyp Turn.
Ein Spieler hat am Ende des Spiels eine Reihe von Zügen (Plays) durchgeführt. Dies wird durch eine Liste von Zügen(Turn) im Typ Plays dargestellt.
An dieser Stelle sei gesagt, dass ich mir bei den Namen der Datentypen, Dateinamen und Funktionen wenige Gedanken über semantische Korrektheit gemacht habe - es möge mir verziehen werden.

Um ein Spiel zu starten, wird die Funktion NewGame() aufgerufen, welche die Liste der Züge initialisiert.
Die Liste kann dann durch die Funktion NewTurn(int,int) erweitert werden.

Damit sind die ersten notwendigen Schritte zur Beschreibung eines Spielzuges fertig:

  • Es ist möglich ein neues Spiel zu beginnen
  • Es ist möglich einen neuen Zug zu machen

Beschreibung der Testfälle

Nachdem wir das Interface des Objekts definiert haben, ist es jetzt an der Zeit die eigentlichen Spielregeln für einen Spielzug zu definieren.
Zur Erinnerung: Es soll lediglich die Regel
Ein Spieler darf in seinem Zug, sein Token auf ein beliebiges freies Feld innerhalb eines 3x3 Grids setzen
überprüft werden.
Da in der derzeitigen Objektstruktur noch keine Informationen über den aktuellen Zustand des Spielfeldes vorhanden sind, werden wir uns auf den Teil des 3x3 Grids konzentrieren.
Für diesen Teil der Spielregeln gibt es 2 logische Testfälle:

  • Der Spieler setzt in ein gültiges Feld (positiv)
  • Der Spieler setzt in ein ungültiges Feld (negativ)

Die Testfälle werden in der Datei turns_test.go (auch diese Datei liegt im Ordner components) beschrieben.

package components

import (
	"testing"
)

func Test_whenTokenIsSetOnAValidField_ThenTheTurnShouldBeExecuted(t *testing.T) {

}

func Test_whenTokenIsSetOnAnInvalidField_ThenTheTurnShouldNotBeExecuted(t *testing.T) {

}

Da die Test-Engine von GoLang keine datengetriebenen Tests zulässt müssen wir die Test programmatisch mit Testdaten anreichern. Ich habe mich dabei an von der Beschreibung eines Blogbeitrags aus 2021 inspirieren lassen.
Zuerst legen wir also den Datentyp des TestCase an, um dann die Funktion für die Positiv-Testfälle zu mit Leben zu füllen

package components

import (
	"fmt"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

type TestCase struct {
	description   string
	input         Turn
	expectedError string
}

func Test_whenTokenIsSetOnAValidField_ThenTheTurnShouldBeExecuted(t *testing.T) {

	scenarios := make([]TestCase, 0)

	for x := 1; x <= 3; x++ {
		for y := 1; y <= 3; y++ {
			scenarios = append(scenarios, TestCase{
				description: fmt.Sprintf("Setting Token to %d %d", x, y),
				input: Turn{
					X: x,
					Y: y,
				},
				expectedError: "",
			})
		}
	}

	for _, scenario := range scenarios {
		t.Run(scenario.description, func(t *testing.T) {
			game := NewGame()
			_, err := game.NewTurn(scenario.input.X, scenario.input.Y)
			assert.Nil(t, err, "Turn should be valid")
		})
	}
}

Die Durchführung des Testfalles müsste 9 positive Testfälle erzeugen - ja das sind leider false-postives, da wir ja noch keine Logik umgesetzt haben.
Die Beschreibung der Negativ-Testfälle erfolgt auf eine ähnliche Art

func Test_whenTokenIsSetOnAValidField_ThenTheTurnShouldBeExecuted(t *testing.T) {

	scenarios := make([]TestCase, 0)

	//test invalid values X
	for _, x := range [...]int{0, 4, 5, -1} {
		for _, y := range [...]int{1, 2, 3} {
			scenarios = append(scenarios, TestCase{
				description: fmt.Sprintf("[VALID Space] %d %d", x, y),
				input: Turn{
					X: x,
					Y: y,
				},
				expectedError: "invalid turn: posittion must be between [1,1] and [3,3]",
			})
		}
	}

	//test invalid values Y
	for _, x := range [...]int{1, 2, 3} {
		for _, y := range [4]int{0, 4, 5, -1} {
			scenarios = append(scenarios, TestCase{
				description: fmt.Sprintf("[INVALID Space] %d %d", x, y),
				input: Turn{
					X: x,
					Y: y,
				},
				expectedError: "invalid turn: posittion must be between [1,1] and [3,3]",
			})
		}
	}

	//test if both values are invalid
	scenarios = append(scenarios, TestCase{
		description: fmt.Sprintf("[INVALID Space] %d %d", 0, 4),
		input: Turn{
			X: 0,
			Y: 4,
		},
		expectedError: "invalid turn: posittion must be between [1,1] and [3,3]",
	})

	for _, scenario := range scenarios {
		t.Run(scenario.description, func(t *testing.T) {
			game := NewGame()
			_, err := game.NewTurn(scenario.input.X, scenario.input.Y)
			require.Error(t, err)
			assert.Equal(t, scenario.expectedError, err.Error())
		})
	}
}

Endlich, wenn wir jetzt die Testfälle durchführen, kommt es zum Fehler. Die Negativ-Testfälle erwarten eine Fehlermeldung invalid turn: posittion must be between [1,1] and [3,3], welche unsere Umsetzung bisher nicht liefert.

Umsetzung der Zuglogik

Um die jetzt fehlgeschlagenen Testfälle positiv testen zu können, muss die Datei turns.go angepasst werden.
Die Anpassungen hier sind recht überschaubar. Die Funktion NewTurn(int, int) muss mit Leben gefüllt werden. Zusätzlich muss eine Funktion zur Prüfung auf valide Felder implementiert werden.

func (playerGame *Plays) NewTurn(x int, y int) (*Plays, error) {
	if !checkValidPositionOnBoard(x, y) {
		return playerGame, errors.New("invalid turn: posittion must be between [1,1] and [3,3]")
	}

	playerGame.playerTurns = append(playerGame.playerTurns, &Turn{X: x, Y: y})
	return playerGame, nil
}

// RULES

func checkValidPositionOnBoard(x int, y int) bool {
	return !(x < 1 || x > 3 || y < 1 || y > 3)
}

Was passiert hier also?
Die Funktion NewTurn(int, int) prüft zuerst, ob der neue Zug innerhalb der gültigen Grenzen 1 <= x <= 3 und 1 <= y <= 3 liegt.
Sollte dem nicht der Fall sein, wird die bisherige Spielzugliste sowie der im Test definierte Fehler invalid turn: posittion must be between [1,1] and [3,3] zurückgeliefert. (Ja der Fehler hat noch Rechtschreibfehler - dies wird in einem späteren Beitrag behoben) Sollte der Zug innerhalb der gültigen Feldgrenzen liegen, wird der Zug an die Liste der Spielzüge angehängt. Die daraus resultierende neue Liste, wird ohne Fehlermeldung an den Aufrufer zurück gereicht.

Nach Anpassung des eigentlich Programm-Codes können die Testfälle erneut durchgeführt werden, und sollten jetzt alle erfolgreich (grün) durchgeführt werden. Tergebnisse im Gitlab

Weitere Schritte

In den kommenden Beiträgen werden wir uns darum kümmern, dann der Zustand des Spielfeldes gespeichert werden kann, sowie die Gewinn-Bedingungen beschreiben. Mit dem bis dahin entstandenen Code soll es weiterhin möglich sein, auch menschliche Spieler als Teilnehmer einzubinden. Danach wird die eigentliche KI implementiert. Die KI wird durch verstärktes Lernen, ein statistisches Modell aufbauen, welches es erlaubt, das Spiel zu “erlernen” und möglichst effektiv zu spielen.
Der hier beschriebene Code kann (mit kleinen Abweichungen) im GitLab eingesehen werden.