Link Search Menu Expand Document

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:

Java Test Runner in VSCode

Java Test Runner in VSCode

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.

Java CI with Maven

Am Beispiel des Commits 29459 sieht man hier das Modal, das sich bei Klick auf den Haken im GitHub UI öffnet:

Java Test Runner in VSCode

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.

codecov

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.

Codecov Grid Coverage Graph

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
0-dominion-plugin-cli 0% (von der
Gesamtcoverage ausgeschlossen)
1-dominion-adapters kein Code vorhanden
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 (Instructions) 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

1. Mock PlayerDecision

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

2. Mock Player

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.