Im folgenden werde ich das Spiel Wizard in C# Programmieren, dabei nutzen wir Test Driven Development. Zum Testen nutzen wir die C# Bibliothek MSTest. Wir werden zuerst mit einer leichteren abgewandelten Form anfangen und dann langsam Features hinzufügen. Wir schreiben immer erst die Tests und Programmieren dann, nach dem Konzept des Test Driven Developments. Zuerst werden wir ein MVP(Minimum Viable Product) entwickeln, dieses wird erstmal nur darin bestehen, das man am Anfang einer Runde 4 Karten bekommt und versucht möglichst viele Stiche zu bekommen, die Karten gehen von 1 - 13 und haben vorerst keine Farbe. Wenn zwei mal eine gleich hohe Zahl gelegt wird, dann gewinnt die 1. Karte. Natürlich schreiben wir erst die Tests, wir machen ja auch Test Driven Development. Los Gehts!
Programmieren der C# MSTest Tests nach dem Test Driven Development
Wir folgen nun dem Test Driven Development Prinzip. Daher schreiben wir zuerst unsere Unit Tests. Dazu nutzen wir die C# Bibliothek MSTest. Die Klasse CardGiver beschreibt den Karten Geber, bzw. die Person welche die Karten mischt. Am Anfang der Runde werden alle Karten gemischt und dann ausgeteilt. Es gibt jede Karte nur einmal, deshalb kann jede Karte auch nur einmal vergeben werden, zu mindestens so lange bis die Karten wieder gemischt werden.
Dies testen wir alles in den untenstehenden Methoden.
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace TestProject1;
[TestClass]
public class CardGiverTest
{
private CardGiver CreateNewCardGiver()
{
return new CardGiver();
}
[TestMethod]
public void TestCreateNewCardGiver()
{
this.CreateNewCardGiver();
}
[TestMethod]
public void TestCardsAreUnique()
{
var cardGiver = this.CreateNewCardGiver();
cardGiver.ShuffleCards();
var cards = cardGiver.takeAllCards();
var setOfCards = new HashSet<Card>(cards);
Assert.AreEqual(cards.Count, setOfCards.Count);
Assert.IsFalse(cardGiver.HasCard());
}
[TestMethod]
public void TestCardsAfterShuffleAreDifferent()
{
var counterNumberOfTimesDifferent = 0;
var cardGiver = this.CreateNewCardGiver();
for (var i = 0; i < 1_000; i++)
{
cardGiver.ShuffleCards();
var cards1 = cardGiver.TakeAllCards();
cardGiver.ShuffleCards();
var cards2 = cardGiver.TakeAllCards();
if ( ! Enumerable.SequenceEqual(cards1, cards2))
{
counterNumberOfTimesDifferent++;
}
}
Assert.IsTrue(counterNumberOfTimesDifferent > 990);
}
[TestMethod]
public void TestNextCard()
{
var cardGiver = this.CreateNewCardGiver();
cardGiver.ShuffleCards();
Assert.IsTrue(cardGiver.HasCard());
var nextCard = cardGiver.GetNextCard();
}
}
Beschreibung unserer MSTests
Im Test TestCreateNewCard testen wir ob wir eine Instanz der Klasse CardGiver erstellen können. Der Test TestCardsAreUnique prüft das jede Karte nur einmal ausgeteilt wird ( Eine Karte kann nur einmal ausgeteilt werden). Durch den Test TestCardsAfterShuffleAreDifferent wird sichergestellt, dass die Karten jedes mal (bzw. in den meisten der Fällen) nach dem Mischen unterschiedlich sind. Da die Karten natürlich auch zufällig mehrmals hintereinander gleich sein könnten. Führen wir das Mischen 100 mal je zwei mal durch und vergleichen die gemischten Karten auf Gleichheit. Wenn von 1000 Mischpaaren mehr als 900 unterschiedlich sind besteht der Test. Im Letzten Test TestNextCard prüfen wir ob, der CardGiver eine Karte hat, welche er uns austeilen kann. Logischerweise sind im Moment alle Tests rot, wir müssen nun versuchen alle Tests grün zu bekommen.
Nun Programmieren wir und versuchen die Tests grün zu bekommen
Wir erstellen die Klasse Card, diese ist sehr unspektakulär, sie speichert im Moment nur eine Zahl. Außerdem implementieren wir nur IComparable, damit Karten miteinander auf Gleichheit kontrolliert werden können. Dies benötigen wir unter anderem fürs Mischen.
namespace WizardGame;
public class Card : IComparable<Card>
{
public int Number { get; private set; }
public Card(int number)
{
Number = number;
}
public int CompareTo(Card? other)
{
if (ReferenceEquals(this, other)) return 0;
if (ReferenceEquals(null, other)) return 1;
return Number.CompareTo(other.Number);
}
}
Nun können wir noch prüfen, ob die Zahl zwischen 1 und 13 ist um Fehler zu vermeiden.
namespace WizardGame;
public class Card : IComparable<Card>
{
private int _number;
public int Number
{
get => _number;
private set
{
if (value is >= 1 and <= 13)
{
_number = value;
}
}
}
public Card(int number)
{
Number = number;
}
public int CompareTo(Card? other)
{
if (ReferenceEquals(this, other)) return 0;
if (ReferenceEquals(null, other)) return 1;
return Number.CompareTo(other.Number);
}
}
So, nun programmieren wir den CardGiver.
Damit wir keine Hardcoded Zahlen in unserem Programm haben, lagern wir alle Einstellungen(verschiedene Konstante Zahlen und Strings) in eine seperate Klasse aus. Wir programmieren die static class GameConfig:
namespace WizardGame;
public static class GameConfig
{
public const int HighestCardNumber = 13;
public const int LowestCardNumber = 1;
}
Nun programmieren wird den CardGiver :
namespace WizardGame;
public class CardGiver
{
private List<Card> CurrentCards { get; set; }
private readonly Random _randomNumberGenerator;
public CardGiver()
{
CurrentCards = new List<Card>();
_randomNumberGenerator = new Random();
}
private List<Card> GenerateAllCards()
{
var cards = new List<Card>();
for (var i = GameConfig.LowestCardNumber; i <= GameConfig.HighestCardNumber; i++)
{
cards.Add(new Card(i));
}
return cards;
}
public void ShuffleCards()
{
var cards = GenerateAllCards();
CurrentCards = cards.OrderBy(_ => _randomNumberGenerator.Next()).ToList();
}
public List<Card> TakeAllCards()
{
var allCards = new List<Card>(CurrentCards);
CurrentCards.Clear();
return allCards;
}
public Card? GetNextCard()
{
if (!HasCard()) return null;
var firstCard = this.CurrentCards[0];
this.CurrentCards.RemoveAt(0);
return firstCard;
}
public bool HasCard()
{
return this.CurrentCards.Any();
}
}
Zwischen Bilanz des Test Driven Developments
Jetzt sind alle Tests grün. Nun wollen wir die nächsten Test programmieren. Der CardGiver soll die Karten an Spieler übergeben, wir sagen dem CardGiver wie viele Karten er jedem Spieler geben soll und welchen Spielern er die Karten geben muss. Dafür muss es natürlich auch noch eine Klasse Spieler geben.
Nächste Test Driven Development Iteration
Da wir nun auch eine Klasse Spieler benötigen, haben wir dafür passende Tests geschrieben. Es soll die Klasse Player geben, der Subtyp HumanPlayer ist ein Menschlicher Spieler, der ComputerPlayer wird vom Computer gesteuert. Der ComputerPlayer wird einfach zufällig Karten legen. Nur der SPieler selbst kann seine eigenen Karten sehen, andere Spiele sehen nur wie viele Karten ein anderer Spieler hat. Der CardGiver kann Spielern Karten geben, Er kann den Spielern aber keine Karten nehmen, auch sonst kann niemand die Karten eines Spielers wegnehmen. In unseren Unit Tests können wir nur auf die öffentlichen Methoden zugreifen, daher können wir nur prüfen ob der Spieler nachdem wir ihm eine Karte gegeben haben eine Karte mehr hat.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WizardGame;
namespace TestProject1;
[TestClass]
public class PlayerTest
{
[TestMethod]
public void CreatePlayer()
{
var humanPlayer = new HumanPlayer();
Assert.IsTrue(humanPlayer.CardCount == 0);
humanPlayer.GiveCard(new Card(2));
Assert.IsTrue(humanPlayer.CardCount == 1);
}
[TestMethod]
public void CreateComputerPlayer()
{
var computerPlayer = new ComputerPlayer();
Assert.IsTrue(computerPlayer.CardCount == 0);
computerPlayer.GiveCard(new Card(2));
Assert.IsTrue(computerPlayer.CardCount == 1);
}
}
Im CardGiver Test prüfen wir nun zusätzlich auch noch ob der Geber, die Karten an die Spieler verteilt hat.
[TestMethod]
public void TestGivePlayerCards()
{
var cardGiver = CreateNewCardGiver();
cardGiver.ShuffleCards();
var testPlayer1 = new ComputerPlayer();
var testPlayer2 = new ComputerPlayer();
var testPlayer3 = new HumanPlayer();
var allPlayers = new List<Player>(){testPlayer1, testPlayer2, testPlayer3};
cardGiver.GivePlayersCards(allPlayers, 3);
Assert.AreEqual(testPlayer1.CardCount, 3);
Assert.AreEqual(testPlayer2.CardCount, 3);
Assert.AreEqual(testPlayer3.CardCount, 3);
Assert.IsTrue(cardGiver.TakeAllCards().Count == 4);
}
}
Tests sind Rot / Wir schreiben zuerst die Tests (TDD)
Nun Programmieren wir den Player. Dafür programmieren wir die abstrakte Klasse Player. Der Player hat eine Liste an Karten. Über die Property CardCount, kann die Anzahl an Karten abgefragt werden. Das Attribut CurrentCards ist aber private sodass nur der Player selbst weiß welche Karten er hat. Über die Methode GiveCard kann man dem Spieler eine Karte geben, dies würde es theoretisch auch anderen Spielern ermöglichen einem Spieler eine Karte zu geben. Das genannte Szenario ist natürlich unlogisch, da wir aber dem CardGiver ermöglichen müssen den Spielern Karten zu geben, haben wir keine andere Wahl.
namespace WizardGame;
public abstract class Player
{
private List<Card> CurrentCards { get; set; }
protected Player()
{
CurrentCards = new List<Card>();
}
public int CardCount => CurrentCards.Count;
public void GiveCard(Card newCard)
{
CurrentCards.Add(newCard);
}
}
Die Klassen HumanPlayer und Computer Player erben von Player haben aber sonst bis jetzt keine Methoden.
public class HumanPlayer : Player
{
}
public class ComputerPlayer : Player
{
}
Tests sind Grün
Nun sind alle Tests außer TestGivePlayerCards grün. Also müssen wir im CardGiver noch eine entsprechende Methode einfügen.
private void GivePlayerCards(Player player, int cardCount)
{
for (var i = 0; i < cardCount; i++)
{
player.GiveCard(this.GetNextCard() ?? throw new InvalidOperationException("Es gibt keine Karten mehr!"));
}
}
public void GivePlayersCards(List<Player> players, int cardCount)
{
foreach (var player in players)
{
GivePlayerCards(player, cardCount);
}
}
Wir schreiben neue Tests mit MSTest
Jetzt sind alle Tests Grün. Das heißt wir müssen wieder neue Tests schreiben, welche prüfen ob die Spieler wenn sie am Zug sind eine Karte abspielen. Der HumanPlayer, legt die Karte welche der Spieler in die Konsole eingegeben hat. Zufälligerweise werden die Karten des ComputerPlayers gelegt. Sies prüfen wir mit folgenden Tests.
[TestMethod]
public void TestHumanPlayerPlayCard()
{
var player = new HumanPlayer();
player.GiveCard(new Card(2));
player.GiveCard(new Card(1));
player.WriteInput(1);
var card = player.PlayCard();
Assert.AreEqual(card.Number, 1);
}
[TestMethod]
public void TestHumanPlayerPlayNotExistingCard()
{
var player = new HumanPlayer();
player.GiveCard(new Card(2));
player.WriteInput(1);
Assert.ThrowsException<InvalidOperationException>(() =>
{
player.PlayCard();
});
}
[TestMethod]
public void TestComputerPlayerPlayCard()
{
var cards = new List<Card>();
for (int i = 0; i < 1000; i++)
{
var player = new ComputerPlayer();
player.GiveCard(new Card(10));
player.GiveCard(new Card(1));
player.GiveCard(new Card(2));
player.GiveCard(new Card(5));
cards.Add(player.PlayCard());
cards.Add(player.PlayCard());
}
Assert.IsTrue(cards.Count(c => c.Number == 1) > 20);
Assert.IsTrue(cards.Count(c => c.Number == 10) > 20);
Assert.IsTrue(cards.Count(c => c.Number == 2) > 20);
Assert.IsTrue(cards.Count(c => c.Number == 5) > 20);
Assert.AreEqual(cards.Count(c => c.Number == 7), 0);
}
Um den HumanPlayer testen zu können, gehen wir davon aus, dass die Methode WriteInput public ist. Der Spieler gibt über die Konsole ein, welche Karte er legen will. Es soll niemand anderes entscheiden können, welche Karte der Spieler als nächste legen muss. Damit wir den HumanPlayer, aber leichter testen können, haben wir die public Methode WriteInput hinzugefügt. Man kann er protected Methoden testen, falls man das Objekt mockt, dies wollen wir jetzt aber erstmal nicht tun.
Wir fügen jetzt einige neue Methoden im HumanPlayer sowie im ComputerPlay hinzu, damit die Farbe der Tests von rot zu grün wechselt.
HumanPlayer
namespace WizardGame;
public class HumanPlayer : Player
{
private int? NextInput { get; set; }
public override Card PlayCard()
{
var nextCard = this.ReadAndDeleteInput();
ThrowExceptionWhenCardNotExists(nextCard);
return this.TakeCard(nextCard);
}
private void ThrowExceptionWhenCardNotExists(int cardNumber)
{
if (!this.LookAtCards().Contains(new Card(cardNumber)))
{
throw new InvalidOperationException("Diese Karte existiert nicht");
}
}
public void WriteInput(int input)
{
if (NextInput is null)
{
this.NextInput = input;
}
}
private int ReadAndDeleteInput()
{
var input = this.NextInput ?? throw new ArgumentException("Es gibt keinen nächsten Input");
this.NextInput = null;
return input;
}
}
ComputerPlayer
namespace WizardGame;
public class ComputerPlayer : Player
{
private readonly Random _random;
public ComputerPlayer() : base()
{
this._random = new Random();
}
private Card RandomlyChooseNextCard()
{
var randomCardIndex = this._random.Next(0, CardCount);
var nextCard = this.LookAtCards()[randomCardIndex];
return this.TakeCard(nextCard);
}
public override Card PlayCard()
{
return this.RandomlyChooseNextCard();
}
}
Jetzt können wir uns um den Spielablauf kümmern. Am Anfang des Spiels werden die Karten ausgeteilt. Danach wird reihum gespielt. Es gibt eine feste Reihenfolge, welche sich nicht verändert. Wenn ein Spieler am Zug ist legt er eine Karte. In jeder Runde gewinnt ein Spieler und zwar, der welche die höchste Karte legte. Wir werden erstmal noch nicht die Stiche mitzählen, sondern die Spieler einfach legen lassen. Damit wir die Spieler auseinander halten können, fügen wir für den Spieler das Attribut "name" hinzu.
public string Name { get; private set; }
private List<Card> CurrentCards { get; set; }
protected Player(string name)
{
Name = name;
CurrentCards = new List<Card>();
}
Wir müssen dazu natürlich auch den Konstruktor vom HumanPlayer und dem ComputerPlayer anpassen. Dort passiert aber nichts besonderes. Um den Spielstand festzuhalten definieren wir GameState.
public struct GameStatus
{
private List<Player> Players { get; }
private int RemainingRounds { get; }
private Card LastCardPlayed { get; }
private Player currentPlayerTurn { get; }
public GameStatus(List<Player> players, int remainingRounds, Card lastCardPlayed, Player currentPlayerTurn)
{
Players = players;
RemainingRounds = remainingRounds;
LastCardPlayed = lastCardPlayed;
this.currentPlayerTurn = currentPlayerTurn;
}
}
Probleme mit den Tests / Probleme des Test Driven Developments
Jetzt implementieren wir das Observer Design Pattern. Das Interface IGameSubscriber bekommt vom Game updates bezüglich des Spielstands. Im Spielstand werden Informationen über die Spieler, die noch zu spielenden Runden, der Spieler am Zug und die zuletzt gespielte Karte gespeichert. Wir programmieren die Klasse GameInformationShower, welche das Game subscribt und dann bei jedem Update Informationen über die jetzige Spielsituation ausgibt. Per Unit Test überprüfen wir, ob der Spielablauf richtig abläuft. Es sollte die Reihenfolge der Spieler eingehalten werden, außerdem sollten sich keine Karten wiederholen. Die Anzahl verbleibender Runden muss herunter gezählt werden.
Da das testen schwierig ist, werden wir es erstmal nicht testen. Teilweise ist es schwer zuerst die Tests zu schreiben, da Methoden privat sind.