5 Unit Tests
Inhalt
10 Unit Tests
Nennung von 10 Unit-Tests und Beschreibung, was getestet wird
Insgesamt wurden bislang 88 Tests implementiert. Eine Auswahl von 13 Tests soll hier vorgestellt werden:
Unit Test | Beschreibung |
---|---|
CardTest::cardInvalidCost | Testet, ob tatsÀchlich eine IllegalArgumentException mit einer bestimmten Nachricht geworfen wird, wenn die Kartenkosten kleiner als 0 sind bei der Initialisierung. |
MoneyCardTest::moneyValue | Testet, ob die moneyCard.getMoney() Methode das richtige Ergebnis nach Initialisierung einer MoneyCard liefert. |
MoneyCardTest::noNegativeMoney | Testest, ob tatsÀchlich eine IllegalArgumentException mit einer bestimmten Nachricht geworfen wird, wenn das Geld auf einer Karte einen Wert kleiner als 0 hat bei der Initialisierung. |
DeckTest::putDrawSameCard | Testet, ob bei einem Deck das Legen einer Karte und anschlieĂenden Nehmen vom Deck wieder die ursprĂŒngliche Karte ergibt. |
DeckTest::drawFromEmptyDeck | Testet, ob eine EmptyDeckException geworfen wird, wenn von einem leeren Kartenstapel eine Karte gezogen wird. |
DeckTest::putAndDrawSeveralCards | Testet, ob das HinzufĂŒgen und Ziehen von mehreren Mock-Karten in verschiedenen Konstellationen funktioniert. |
DeckTest::putMultipleCardsOnDeck | Testet, ob die Methode deck.putCardSeveralTimes(card, count) entsprechend ihrer Aufgabe korrekt funktioniert und tatsÀchlich dieselbe Karte mehrfach auf den Stapel gelegt wird. |
DeckTest::shuffleDeck | Testet, ob der verwendete Algorithmus zum Mischen gut genug ist und kein ganz schlechter Pseudo-Zufallsgenerator. |
DisposeMoneyCardTakeMoneyCardTest ::mayChooseACardToDisposeNoMust | Testet, ob tatsÀchlich nichts an den Handkarten geÀndert wird, wenn bei dieser Instruktion keine Karte zum Entsorgen ausgewÀhlt wird. |
DisposeMoneyCardTakeMoneyCardTest ::newCardOnHandOldCardDisposed | Testet, ob der Mechanismus zum AuswÀhlen einer Karte zum Entsorgen und dem Nachziehen einer Karte korrekt funktioniert. |
DisposeMoneyCardTakeMoneyCardTest ::canOnlyTakeMoneyCards | Testet, ob nur Geldkarten zum Entsorgen ausgewÀhlt werden können. |
EarnMoneyInstructionTest::earn42Money | Testet, ob der Mechanismus zum Verdienen von Geld in einer Anweisung (Instruction ) korrekt funktioniert und sich der MoveState entsprechend Àndert. |
EarnMoneyInstructionTest::earnNoNegativeMoney | Testet, ob kein negatives Geld verdient werden kann und in diesem Fall eine IllegalArgumentException geworfen wird. |
ATRIP: Automatic
BegrĂŒndung/ErlĂ€uterung, wie âAutomaticâ realisiert wurde
Damit Tests auch genutzt werden, sollten sie wĂ€hrend der Entwicklung auf Knopfdruck ausfĂŒhrbar sein und automatisch ablaufen. Dazu wurde eine Extension fĂŒr Visual Studio Code namens âTest Runner for Javaâ verwendet, die einwandfrei funktionierte. Tests können damit direkt mittels eines Klicks auf einen neben der Signatur befindlichen Button ausgefĂŒhrt und sogar debugged werden. Alle Tests können zudem auf einmal im Test Runner ausgefĂŒhrt werden. Die Screenshots geben einen kleinen Einblick:
DarĂŒber hinaus wurden die Tests in den GitHub Actions Workflow eingebunden. ZunĂ€chst werden die Tests auch bei mvn verify
ausgefĂŒhrt. SchlĂ€gt hier ein Test fehl, dann stoppt der gesamte Build und in GitHub wird ein entsprechender Hinweis angezeigt. AnschlieĂend wird der von Jacoco generierte XML-Coverage-Report auf Codecov hochgeladen. Dieses Tool erlaubt es, die Test und insbesondere die Test Coverage detaillierter zu untersuchen. Die Code Coverage wird anschlieĂend auch auf SonarQube (SonarCloud) hochgeladen.
Am Beispiel des Commits 29459
sieht man hier das Modal, das sich bei Klick auf den Haken im GitHub UI öffnet:
Per Klick auf âDetailsâ gelangt man dort zu folgenden Seiten:
ATRIP: Thorough
Jeweils ein positives und negatives Beispiel zu âThoroughâ; jeweils Code-Beispiel, Analyse und BegrĂŒndung, was professionell/nicht professionell ist
Tests sollten vollstĂ€ndig sein und alles Notwendige ĂŒberprĂŒfen. Was notwendig ist liegt dabei im Ermessen der Entwicklerin.
Positiv-Beispiel
FĂŒr die DisposeMoneyCardTakeMoneyCardToHandInstruction
wurde die execute()
-Methoden umfangreich im DisposeMoneyCardTakeMoneyCardTest
getestet.
Da der Source Code fĂŒr die Tests dieser Instruktion 156 Zeilen lang ist, werden hier nur die hoffentlich selbsterklĂ€renden Testnamen aufgefĂŒhrt:
- mayChooseACardToDisposeNoMust()
- noMoneyCardsOnHand()
- newCardOnHandOldCardDisposed()
- canOnlyTakeMoneyCards()
- canOnlyTakeMoneyCardsThatCostMaxThreeMore()
- noMoneyCardsOnStock()
âProfessionellâ ist, dass durch diese Tests alle 20 Codezeilen von execute()
getestet werden konnten. Die assert-Statements wurden sorgfĂ€ltig angelegt und testen beispielsweise, ob die neuen Handkarten des Spielers nach AusfĂŒhrung der Anweisung auch tatsĂ€chlich den erwarteten Karten entsprechen.
@Test
void newCardOnHandOldCardDisposed() {
when(decision.chooseOptionalMoneyCard(any())).thenReturn(
Optional.of(CardPool.silverCard)); // dispose this card
when(decision.chooseMoneyCard(anyList())).thenReturn(
CardPool.goldCard); // take this card
instruction.execute(player, new MoveState(), new GameStock());
assertThat(player.getHand())
.doesNotContain(CardPool.silverCard)
.contains(CardPool.goldCard);
}
Wie hier zu erkennen ist wurden in manchen Test-Methoden sogar die Arrange/Act/Assert-Phasen visuell durch eine leere Zeile getrennt. Dass ausfĂŒhrlich getestet wurde, zeigt beispielsweise auch der PlayerMoveActionPhaseTest
. Insbesondere wurden hier im Zusammenspiel mit Mock-Objekten ausfĂŒhrliche Tests angestellt (siehe verify()
statements).
@Test
void playTwoActionCards() {
Instruction instr = mock(Instruction.class);
Instruction instr2 = spy(earnActionInstruction);
ActionCard playCard = new ActionCard("action card 1",
CardType.ACTION, 1, new Action(instr, instr2));
Instruction instr3 = mock(Instruction.class);
Instruction instr4 = mock(Instruction.class);
ActionCard otherPlayCard = new ActionCard("action card 2",
CardType.ACTION, 2, new Action(instr3, instr4));
when(player.getActionCardsOnHand())
.thenReturn(List.of(playCard, otherPlayCard))
.thenReturn(List.of(otherPlayCard)); // stub consecutive call
when(decision.chooseOptionalActionCard(anyList()))
.thenReturn(Optional.of(playCard))
.thenReturn(Optional.of(otherPlayCard));
PlayerMove move = new PlayerMove(player, new GameStock());
move.doActionPhase();
verify(player, times(2)).getActionCardsOnHand();
verify(decision, times(2)).chooseOptionalActionCard(any());
verify(information, never()).noActionCardsPlayable();
verify(instr, only()).execute(any(), any(), any());
verify(instr2, only()).execute(any(), any(), any());
verify(instr3, only()).execute(any(), any(), any());
verify(instr4, only()).execute(any(), any(), any());
}
Negativ-Beispiel
Ein Negativ-Beispiel fĂŒr vollstĂ€ndiges Testen liefert der GameTest
. Da die Klasse Game
hauptsĂ€chlich aus privaten Methoden besteht, konnte hier leider nur ĂŒberprĂŒft werden, ob sich ein Objekt ohne Fehler erstellen lĂ€sst:
@Test
void gameInitializationWithoutError() {
List<String> playerNames = List.of("Player1", "Player2");
PlayerInteraction interaction = new PlayerInteraction(decision, information);
new Game(interaction, playerNames);
verifyNoMoreInteractions(decision);
verifyNoMoreInteractions(information);
}
Das Design der Klasse Game
könnte sicherlich angepasst werden, sodass beispielsweise private Methoden in eigene Klassen ausgelagert werden und dann besser testbar sind. Dies hÀtte jedoch die kleine Game
-Klasse unnötig âentzerrtâ und fĂŒr ein schlechteres VerstĂ€ndnis des Codes gefĂŒhrt. Mithilfe von Powermock hĂ€tten auch private Methoden getestet werden können; leider ist die Library aber noch nicht fĂŒr JUnit5 verfĂŒgbar.
ATRIP: Professional
Jeweils ein positives und negatives Beispiel zu âProfessionalâ; jeweils Code-Beispiel, Analyse und BegrĂŒndung, was professionell/nicht professionell ist
Tests sollten den gleichen QualitĂ€tsstandards wie âProduktivcodeâ unterliegen, da auch hier Fehler teuer sind.
Positiv-Beispiel
Als Positiv-Beispiel soll erneut der DisposeMoneyCardTakeMoneyCardTest
dienen. Insbesondere zeugen diese Zeilen von âprofessionellemâ Code:
@BeforeEach
void prepare() {
instruction = new DisposeMoneyCardTakeMoneyCardToHandInstruction();
drawDeck = new Deck();
drawDeck.put(CardPool.provinceCard);
drawDeck.put(CardPool.duchyCard); // â other cards on bottom of draw deck
drawDeck.put(CardPool.goldCard); // 5 cards until here
drawDeck.put(CardPool.silverCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.curseCard);
drawDeck.put(CardPool.estateCard);
MockitoAnnotations.openMocks(this);
PlayerInteraction interaction = new PlayerInteraction(decision, information);
player = new GamePlayer("awesome player", interaction, drawDeck, new GameStock());
}
private void expectNoChangesToHand(Player player) {
assertThat(player.getHand()).containsExactlyInAnyOrderElementsOf(
List.of( // no changes to hand
CardPool.estateCard,
CardPool.curseCard,
CardPool.copperCard,
CardPool.silverCard,
CardPool.goldCard));
}
Hier fÀllt zunÀchst positiv auf, dass die von JUnit bereitgestellte Annotation @BeforeEach
Verwendung bei der Methode void prepare()
findet. Hier werden Vorbereitungen getroffen, die ansonsten in jedem Test eigenstĂ€ndig notwendig gewesen wĂ€ren und zu viel dupliziertem Code gefĂŒhrt hĂ€tten. Beispielsweise wird hier das drawDeck
(Nachziehstapel) des Spielers mit Karten populiert sowie die Mock-Objekte initialisiert. Ferner fÀllt positiv auf, dass eine sehr hÀufig benutzte Assertion in eine eigene Methode private void expectNoChangesToHand(Player player)
ausgelagert wurde, um erneut Code-Duplikationen zu vermeiden.
Negativ-Beispiel
Im Test buyOneCard()
in PlayerMoveBuyingPhaseTest
sind diese Zeilen anzutreffen:
Deck drawDeck = new Deck();
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard); // â 4 additional cards for next round
drawDeck.put(CardPool.duchyCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard);
Hier hÀtte man gleiche Statements durch eine For-Schleife vereinfachen können und dadurch Line Duplications vermeiden können. Dazu habe ich mich hier jedoch bewusst dagegen entschieden, um deutlicher zu machen, welche Karten nun auf dem Nachziehstapel liegen.
DarĂŒber hinaus hĂ€tte man den Test playTwoActionCards()
sicherlich etwas vereinfachen oder in einzelne, kleinere Methoden auslagern können, da bei dieser GröĂe teilweise schon die Ăbersichtlichkeit verloren geht.
Code Coverage
Code Coverage im Projekt analysieren und begrĂŒnden
FĂŒr die Analyse der Code Coverage wurde JaCoCo (Java Code Coverage Library) eingesetzt, indem ein eigenes Modul dominion-report
erstellt und darin das Ziel report-aggregate
ausgefĂŒhrt wurde. Der auf diese Weise entstandene Report wurde â wie bereits bei âATRIP: Automaticâ erwĂ€hnt â mittels GitHub Actions zu Codecov gepushed. Dadurch können wir mit jedem Commit nachvollziehen, wie viel Prozent unseres gesamten Codes sowie des Diffs (neuer Code) abgedeckt wird.
Grid-Graph
Anhand des Grid-Graphen ist bereits ersichtlich, dass groĂe Teile des Codes von Tests abgedeckt sind. Der groĂe rote Block steht fĂŒr die schwer zu testende Klasse Game
. Andere orangene oder rote Blöcke bezeichnen hĂ€ufig Value Objects, die nur aus einfachen Gettern/Settern bestehen und daher keiner Tests bedĂŒrfen.
Code-Coverage pro Modul
Explizit aus der Code-Coverage herausgenommen wurde (auch im obigen Grid-Graph) die Ă€uĂerste Schicht âPlugin-CLIâ. Diese beinhaltet den kurzlebigsten Code, sodass es im Rahmen dieses Projekts keinen Sinn ergab, die Schicht mit zu testen.
Modul | Coverage |
---|---|
Gesamtcoverage ausgeschlossen) | |
2-dominion-application | ca. 87% |
3-dominion-domain | ca. 93% |
Total | ca. 89% |
Besonders in der innersten Schicht, dem DomÀnen-Code, wurde mit 93% eine hohe Testabdeckung erreicht, die wichtig ist, um KernfunktionalitÀten und grundlegende Bausteine des Spiels wie Karten und Kartenstapel umfassend zu testen. In der Applikationsschicht wurden beispielsweise die Anweisungen (Instruction
s) auf ihre Korrektheit getestet oder auch die verschiedenen Phasen eines Zuges (PlayerMove
: Action Phase, Buying Phase und Clean Up Phase).
Fakes und Mocks
Analyse und BegrĂŒndung des Einsatzes von 2 Fake/Mock-Objekten; zusĂ€tzlich jeweils UML Diagramm der Klasse
Mockito ist eine Mock-Library fĂŒr Java, mit der Stellvertreter fĂŒr Objekte definiert werden können. Dadurch ist es möglich, bei Unit-Test tatsĂ€chlich nur die âUnitâ zu testen ohne AbhĂ€ngigkeiten zu anderen Komponenten. Mock-Objekte ersetzen komplexe Objekte durch gleichartige Objekte mit minimaler Funktion, die jedoch fĂŒr die Tests ausreichend sind.
1. Mock
Das Interface PlayerDecision
wurde in den Unit Tests sehr hĂ€ufig âgemocktâ, so zum Beispiel auch im PlayerMoveActionPhaseTest
.
@Test
void cantPlaySameActionCardMultipleTimes() {
Instruction instr = mock(Instruction.class);
Instruction instr2 = spy(earnActionInstruction);
ActionCard playCard = new ActionCard("a",
CardType.ACTION, 1, new Action(instr, instr2));
Deck drawDeck = new Deck();
drawDeck.put(playCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard);
drawDeck.put(CardPool.copperCard);
PlayerInteraction interaction = new PlayerInteraction(decision, information);
GamePlayer ourPlayer = spy(new GamePlayer("our player", interaction, drawDeck, new GameStock()));
when(decision.chooseOptionalActionCard(anyList()))
.thenReturn(Optional.of(playCard));
PlayerMove move = new PlayerMove(ourPlayer, new GameStock());
move.doActionPhase();
verify(ourPlayer, times(2)).getActionCardsOnHand();
verify(decision, times(1))
.chooseOptionalActionCard(actionCardListCaptor.capture());
assertThat(actionCardListCaptor.getValue()).isEqualTo(List.of(playCard));
}
Im Test cantPlaySameActionCardMultipleTimes()
wird dann der Return-Wert bei Aufruf der Funktion chooseOptionalActionCard(...)
festgelegt:
when(decision.chooseOptionalActionCard(anyList()))
.thenReturn(Optional.of(playCard));
AnschlieĂend wird ĂŒberprĂŒft, ob diese Methode genau einmal aufgerufen wurde. Dabei wird gleichzeitig mithilfe eines Argument Captors der an die Funktion ĂŒbergebene Parameter abgefangen und einem Test unterzogen:
verify(ourPlayer, times(2)).getActionCardsOnHand();
verify(decision, times(1))
.chooseOptionalActionCard(actionCardListCaptor.capture());
assertThat(actionCardListCaptor.getValue()).isEqualTo(List.of(playCard));
Der Vorteil von âMockingâ mittels Mockito wird hier sehr deutlich. Andernfalls hĂ€tten wir eine DummyPlayerDecision
manuell implementieren, das heiĂt alle Methoden des Interface PlayerDecision
ĂŒberschreiben mĂŒssen. Mockito nimmt uns diesen Schritt ab, sodass wir mit when(functionCall).thenReturn(...)
unkompliziert nur die eine Methode explizit nÀher spezifizieren, die wir auch tatsÀchlich im Test benötigen.
2. Mock
In derselben Klasse PlayerMoveActionPhaseTest
wurde auch der Player
gemockt, um dann beispielsweise im Test playTwoActionCards()
Verwendung zu finden:
@Test
void playTwoActionCards() {
Instruction instr = mock(Instruction.class);
Instruction instr2 = spy(earnActionInstruction);
ActionCard playCard = new ActionCard("action card 1",
CardType.ACTION, 1, new Action(instr, instr2));
Instruction instr3 = mock(Instruction.class);
Instruction instr4 = mock(Instruction.class);
ActionCard otherPlayCard = new ActionCard("action card 2",
CardType.ACTION, 2, new Action(instr3, instr4));
when(player.getActionCardsOnHand())
.thenReturn(List.of(playCard, otherPlayCard))
.thenReturn(List.of(otherPlayCard)); // stub consecutive call
when(decision.chooseOptionalActionCard(anyList()))
.thenReturn(Optional.of(playCard))
.thenReturn(Optional.of(otherPlayCard));
PlayerMove move = new PlayerMove(player, new GameStock());
move.doActionPhase();
verify(player, times(2)).getActionCardsOnHand();
verify(decision, times(2)).chooseOptionalActionCard(any());
verify(information, never()).noActionCardsPlayable();
verify(instr, only()).execute(any(), any(), any());
verify(instr2, only()).execute(any(), any(), any());
verify(instr3, only()).execute(any(), any(), any());
verify(instr4, only()).execute(any(), any(), any());
}
Hier wurde der Aufruf von player.getActionCardsOnHand()
gemockt, und zwar so, dass zwei nacheinander stattfindende Methodenaufrufe unterschiedliche Werte (hier Listen) zurĂŒckgeben.
when(player.getActionCardsOnHand())
.thenReturn(List.of(playCard, otherPlayCard))
.thenReturn(List.of(otherPlayCard)); // stub consecutive call
Das entsprechende Verhalten wird dann mit verify()
unter Verwendung des Verification Modes times()
ĂŒberprĂŒft:
verify(player, times(2)).getActionCardsOnHand();
Der Player
wurde hier gemockt, damit wir ĂŒberprĂŒfen konnten, ob eine seiner Methoden (getActionCardsOnHand
) tatsÀchlich so oft aufgerufen wurde wie erwartet (hier: zwei mal). Das Mock-Objekt hat (wie vorhin auch) nur das aktuell notwendige Verhalten des Interfaces / der abstrakten Klasse (hier: Player
) abgebildet. Mockito hat uns hierbei geholfen: wir mussten lediglich die Methode when(...)
aufrufen.