Follow my new blog

Samstag, 20. November 2010

Spiel, Satz, Sieg fürs Nachdenken

imageGerade hat die .NET Online User Group die Kata Tennis beim online Coding Dojo bearbeitet. Leider konnte ich nicht teilnehmen. Da in Twitter dazu aber noch anschließend diskutiert wurde, habe ich mir gedacht: Warum nicht die Aufgabe nachträglich angehen?

Meine Lösung liegt hier in meinem Mercurial Google Repository. Anders als im Coding Dojo bin ich jedoch nicht streng nach TDD vorgegangen. Deshalb ist die Struktur der Implementation anders als bei den Dojo-Teilnehmern und auch meine Tests sehen anders aus.

Zum Hintergrund meiner Lösung an dieser Stelle daher ein paar kurze Worte.

1. Der API

Bevor ich mit der Implementation losgelegt habe, habe ich über die Lösung nachgedacht. Wie könnte die aussehen? Nicht nur an der “Oberfläche”, Stichwort API, sondern auch darunter.

Der API – der immer explizit vor Codierungsstart festgelegt werden sollte, wie ich meine – sieht so aus:

var g = new Game();
g.AddScore(Players.Player1);
g.AddScore(Players.Player2);
g.AddScore(Players.Player1);

Console.WriteLine(g.Winner);

Bei mir geht es also nur darum festzustellen, ob durch einen Ballsieg ein Gewinn eingetreten ist und wer der Gewinner ist. Die Aufgabenstellung der Kata Tennis lässt diese Interpretation zu. Dort ist nämlich von gar keinem API die Rede; es sollen lediglich “irgendwie” die Regeln implementiert werden.

2. Das Modell

Ausgehen vom API habe ich mir Gedanken gemacht, wie denn so ein Tennisspiel intern überhaupt repräsentiert werden könnte. Sofort fiel mir da ein Zustandsautomat ein. Die Zustände sind die Spielstände eines Spielers, die Übergänge ergeben sich aus Gewinn eines Balls bzw. ob ein Ball verloren wurde (rot). Hier meine Skizze, die ich auf meinem iPad gemacht habe:

image

So ein Zustandsautomat ist natürlich eine sehr abstrakte Funktionseinheit. Wo und wie läuft der denn im Zusammenhang, so dass er vom API genutzt wird? Das habe ich mit einem kleinen EBC-Diagramm für den AddScore() API-Aufruf modelliert:

image

Der Spieler, der einen Ball gewonnen hat, fließt hinein und wird übersetzt in eine Position für jeden der beiden Spieler. Beide Spieler sind repräsentiert durch ein Objekt (Player), das ihren Zustand hält. Dort läuft der Zustandsautomat. Der liefert nach einer Transition den neuen Spieler-Spielstand an ein abschließendes Bauteil, das ermittelt, wer gewonnen hat.

Beide Skizzen zusammen haben mich, hm, 10-15 Minuten gekostet. Der Zustandsautomat hatte daran den Löwenanteil, würde ich sagen. Er diente ja aber nicht nur der Lösungsmodellierung, sondern auch noch dem Anforderungsverständnis.

Am Ende der Modellierung kannte ich dann alle relevanten Funktionseinheiten und konnte loslegen – und zwar wo ich wollte. Denn die Funktionseinheiten sind ja durch EBC wunderbar entkoppelt.

3. Implementation

Die Implementation habe ich mit den beiden kleinsten Funktionseinheiten begonnen: Ballgewinner in Position übersetzen und Gewinner aus den Spieler-Spielständen ermitteln. Die Herausforderung Zustandsautomat hab ich also an den Schluss gelegt.

Die ersten beiden Funktionseinheiten waren so klein, dass ein Test-first Ansatz nicht nötig war. Ich habe sie deshalb einfach runtergeschrieben und danach Tests geschrieben. Das war ohne Verlust an Korrektheit befriedigender.

Test-first/TDD sollte ja kein Dogma sein. Wenn die Zwecke von TDD anders/leichter erreicht werden können, dann sollte man den leichteren Weg gehen. Und was sind die Hauptzwecke von TDD? 1. Code in überschaubare, leicht testbare Einheiten strukturieren; 2. Eine gute Testabdeckung sicherstellen. Beides habe ich durch die Modellierung erreicht. Denn die hat zu kleinen Funktionseinheiten geführt – ohne später refaktorisieren zu müssen –, die testbar sind und die ich mit wenigen Tests gut abdecken kann – auch im Nachhinein.

Bei der Implementation des Players habe ich dann jedoch nach Test-first gearbeitet. Viel herausgekommen ist dadurch jedoch nicht ;-) Denn auch der Player ist am Ende so einfach mit dem Zustandsautomaten, dass sich eine weitere Zerlegung nicht lohnt:

internal class Player
{
    private readonly Dictionary<ScoringPositions,
                                Dictionary<Scores, Scores>>
                                _stateTransitions =
        new Dictionary<ScoringPositions, Dictionary<Scores, Scores>>
            {
                {ScoringPositions.YouWin,
                    new Dictionary<Scores, Scores>
                    {
                        {Scores.Love, Scores.Fifteen},
                        {Scores.Fifteen, Scores.Thirty},
                        {Scores.Thirty, Scores.Forty},
                        {Scores.Forty, Scores.Win},
                        {Scores.Deuce, Scores.Advantage},
                        {Scores.Advantage, Scores.Win},
                        {Scores.Win, Scores._Invalid},
                        {Scores._Advantage, Scores.Deuce},
                        {Scores._Win, Scores._Invalid},
                    }},
                {ScoringPositions.YouLoose,
                    new Dictionary<Scores, Scores>
                    {
                        {Scores.Love, Scores.Love},
                        {Scores.Fifteen, Scores.Fifteen},
                        {Scores.Thirty, Scores.Thirty},
                        {Scores.Forty, Scores.Forty},
                        {Scores.Deuce, Scores._Advantage},
                        {Scores.Advantage, Scores.Deuce},
                        {Scores.Win, Scores._Invalid},
                        {Scores._Advantage, Scores._Win},
                        {Scores._Win, Scores._Invalid},
                    }},    };
    internal Scores _currentState;
 
    public void Adjust_score(ScoringPositions position)
    {
        _currentState = _stateTransitions[position][_currentState];
        this.Out_CurrentScore(_currentState);
    }

 
    public void In_Deuce()
    {
        _currentState = Scores.Deuce;
    }
 
    public Action<Scores> Out_CurrentScore;
}

Etwas zusammenreißen musste ich mich vielmehr bei den Tests. Ich war schon dabei, möglichst viele Zustandsübergänge zu testen, als ich merkte, dass ich damit “Feedback” verzögerte. Also habe ich nur ein paar essenzielle Transitionen geprüft und mich dann an die Integration aller Bauteile gemacht.

Damit konnte ich dann schon “beweisen”, dass das Gesamtmodell grundsätzlich funktioniert. Zwei Szenarien zeigen Funktionsweise und Umgang mit dem API. Hier zum Beispiel ein Spiel mit Gewinn nach Tie Break:

[Test]
public void Game_with_a_tie_break()
{
    var sut = new Game();
    sut.AddScore(Players.Player1);
    Assert.AreEqual(Players.None, sut.Winner);
    sut.AddScore(Players.Player2);
    Assert.AreEqual(Players.None, sut.Winner);
    sut.AddScore(Players.Player1);
    Assert.AreEqual(Players.None, sut.Winner);
    sut.AddScore(Players.Player2);
    Assert.AreEqual(Players.None, sut.Winner);
    sut.AddScore(Players.Player1);
    Assert.AreEqual(Players.None, sut.Winner);
    sut.AddScore(Players.Player2);
    Assert.AreEqual(Players.None, sut.Winner);
    sut.AddScore(Players.Player1);
    Assert.AreEqual(Players.None, sut.Winner);
    sut.AddScore(Players.Player1);
    Assert.AreEqual(Players.Player1, sut.Winner);
}

Fazit

Ich bin zufrieden mit meinem Vorgehen. Erst modellieren – ja, auch in so kleinen Szenarien – und dann moderat testgetrieben implementieren hat mich schnell, flexibel und sicher gemacht. Alle Funktionseinheiten waren überschaubar und gut testbar. Ich konnte mich auf die Implementation konzentrieren, ohne immer wieder über Refaktorisierungen nachdenken zu müssen.

Es scheint mir ein Nachteil von TDD zu sein, die Modi Implementation und Refaktorisierung so zu verquicken. In der Schrittfolge red-green-refactor sind sie zwar getrennt, doch da diese Schleife immer wieder und schnell in kleinen Schritten durchlaufen werden soll, verschmelzen Implementation (red-green) und Refaktorisierung wie die Einzelbilder eines Films. Als Entwickler muss ich schwebende Aufmerksamkeit für beide haben.

Bei meinem Vorgehen hingegen sind beide Modi klar getrennt. Ergebnis der Modellierung sind Funkionseinheiten, die zunächst mal nicht mehr refaktorisiert werden müssen. Solange ich die implementiere, kann ich mich auf die Implementation konzentrieren. Das entlastet mich mental. Wenn ich damit dann fertig bin – z.B. nach einer Stunde wie in diesem Beispiel –, dann kann ich mich zurücklehnen und den Modus wechseln. Das empfinde ich als Entspannung und wertvolle Phase der Reflexion. Dann habe ich die “Ruhe des Erfolgs” in mir, weil ich ja schon eine Menge geschafft habe.

Ergo: Ich finde weiterhin, dass Implementierung ohne Modellierung harte Arbeit ist und nicht smarte. Man kommt damit auch zum Ziel – aber warum so anstrengen, wenn es auch einfacher geht? TDD ist ne gute Sache – in Maßen. Es ist keine Silberkugel, sondern nur eine Methode unter vielen, die im Zusammspiel zur Produktion von Code genutzt werden sollten. Und somit empfände ich es als künstliche oder gar unrealistische Reduktion, wenn ein Coding Dojo sich nur auf die Anwendung von TDD zur Lösungsfindung beschränken würde.

PS: Danke an Krzysztof Eraskov für seine Hinweise auf Fehler in der Implementierung und Inkonsistenzen im Modell.

3 Kommentare:

Unknown hat gesagt…

Hi Ralf,

erstmal cool das Du auch ne Tennis Version beigesteuert hast. Sehr interessant die einzelnen Versionen miteinander zu vergleichen.

Zustände zu identifizieren, finde ich grundsätzlich das richtige Vorgehen bei der Kata. Allerdings kann ich mich nicht so recht mit der von Dir gewählten Variante mit dem Zustandsautomat anfreunden. Warum so abstrakt? Warum so viel Zeremonie? Eine Alternative wäre bsp. das State-Pattern. Finde ich persönlich deutlich einfacher und lesbarer.

Eine weitere Sache, die mir aufgefallen ist, sind die Tests für die als intern deklarierten Funktionseinheiten. Implementierungsdetails in den Tests halte ich für keine gute Sache. Ich finde man sollte so was in Tests vermeiden, da man solche Tests wegwerfen kann/muss wenn sich die interne Implementierung ändert.

Vielleicht bin ich an ner anderen Ecke auch etwas dogmatisch, aber der Einsatz von Asserts in dem Test mit dem der Blogpost endet halte, ist auch für problematisch. Meines Erachtens hast Du da mehrere Szenarios, die besser in verschiedene Tests aufgeteilt sein müssten, in einen Jumbo-Test gestopft.

Nichtsdestotrotz freu ich mich auf die nächste Kata-Runde. Total cool die einzelnen Ansätze zu sehen.

VG
Björn

Ralf Westphal - One Man Think Tank hat gesagt…

@Björn: Ähm, warum ist ein Zustandsautomat "Zeremonie"? Und was bringt das State-Pattern?

State-Pattern bezieht sich nur darauf, wo Zustand gehalten wird. Der Zustand ist dann aber immer noch Zustand. Beim Tennis muss der Zustand aber wechseln. Wo steht denn der Code dafür? Der hat nix mit State-Pattern zu tun. Da kommt der Zustandsautomat ins Spiel. Der stellt eine sehr einfache Implementation von Regeln dar, weil deklarativ.

Die Alternative zum Zustandsautomaten ist nicht das State-Pattern, sondern eine andere Implementierung der Zählregeln.

Was du mit deinen Gamestates machst, ist nichts anderes, als einen Zustandsautomaten zu implementieren. Du nennst ihn nur nicht so - und deshalb ist er verteilt über mehrere Klassen und - so emfinde ich - schwer zu fassen.

Internes testen: Internes ist aus Sicht eines Anwenders natürlich Implementierungsdetail. Aber warum soll ich das nicht direkt testen. Warum der Dogmatismus, alles nur durch den API zu testen? Warum nur Blackbox Tests? Oder andersrum: Warum alles, was man testen will, public machen?

Ne, da kann ich dir nicht folgen, dass man Internes nicht direkt testen soll. Das ist schlicht pragmatisch.

Jumbotest? Ne, das ist kein Jumbotest am Ende, sondern ein Integrationstest. Der zeigt, dass die Integration im Sinne von Szenarios funktioniert und dokumentiert dabei gleichzeitig.

Eigentlich braucht es nur einen Integrationstest, denn dass korrekt "weitergeschaltet" wird und die Geiwnnermittlung stattfindet, weiß man danach - und dass die Einzelschritte gehen, weiß ich durch die Unit Tests. Dennoch habe ich zwei Integrationstests, um eben komplette Szenarios zu zeigen. Nicht für die Korrektheit, sondern für die "Greifbarkeit", das Verständnis von Problem und Lösung.

Ralf Westphal - One Man Think Tank hat gesagt…

Vor weiteren Kommentaren den zugehörigen Blogbeitrag lesen: http://ralfw.blogspot.com/2010/11/wider-die-patternmania.html