Testing

Modul #J4

Ziele

  • Ich weiss, warum das Testen in der Softwareentwicklung eine zentrale Bedeutung hat.
  • Ich kenne die relevantesten gängigen Testarten in der Software-Entwicklung und deren Zweck.
  • Ich weiss, was Testmanagement ist und was es dabei zu beachten gilt.
  • Ich kenne die wichtigsten Funktionen des Frameworks JUnit 5.
  • Ich kann für einfache Anwendungen selber Unit-Tests implementieren.
  • Ich kenne die wichtigsten Funktionen des Frameworks Mockito.
  • Ich weiss, was Mocks und Spies sind und kenne den Unterschied dazwischen.
  • Ich weiss, was Test-Driven-Development ist und wie ich diese Methodik anwenden kann.

Einführung

Tests in den unterschiedlichen Phasen der Softwareentwicklung dienen dazu festzustellen, ob die entwickelte Software die spezifizierten Anforderungen erfüllt oder nicht. Ausserdem können Tests Mängel in dem produzierten Code aufdecken noch bevor der Code produktiv geschaltet wird und stellen damit sicher, dass das Endprodukt fehlerfrei funktioniert.

Tests sind vor allem aus folgenden Gründen sehr wichtig und sollen entsprechend sehr früh (dazu später) in den Entwicklungsprozess integriert werden:

  • Testing erhöht die Qualität des entwickelten Produkts, da damit Mängel frühzeitig entdeckt und beseitigt werden können.
  • Testing gibt mehr Sicherheit bei Änderungen am Code, da damit sichergestellt werden kann, dass die Änderung nicht zu unerwünschten Nebenwirkungen geführt haben.
  • Testing spart Geld da damit weniger Nachbearbeitungsaufwand in Form von Bug/Hot-Fixes betrieben werden muss.
  • Testing führt zu höherer Kundenzufriedenheit da damit weniger Fehler den Kunden davor hindern, fehlerfrei mit dem Produkt zu arbeiten.

Kostenverlauf für Fehler je nach Zeitpunkt

Testarten

Es gibt viele verschiedene Softwaretestverfahren und Methoden, mit denen sichergestellt werden kann, dass Änderungen am Code wie erwartet funktionieren.

Softwaretests können in zwei Bereiche unterteilt werden: manuelles Testen und automatisiertes Testen. Beim manuellen Testen werden Testfälle manuell durch einen Menschen und ohne Unterstützung durch Werkzeuge oder Skripte ausgeführt.
Beim automatisierten Testen werden Testfälle jedoch mithilfe von Tools, Skripten und Software ausgeführt.

Hier werden wir uns auf das automatisierte Testen konzentrieren, da dieses im Softwareentwicklungsprozess essenziell ist.

Unit-Tests

Unit-Tests sind inhaltlich sehr simpel und erfolgen nah an der Quelle der Anwendung.
Sie dienen zum Testen einzelner Methoden und Funktionen der von der Software verwendeten Klassen, Komponenten oder Module.

Mit Unit-Tests stellen wir sicher, dass einzelne Funktionsblöcke einer Applikation genau das machen, was sie sollen. Vielen passiert es am Anfang, dass nur “Positiv-Fälle” getestet werden. Konkret heisst das, dass der getestete Teil nur im Rahmen des korrekten, erwarteten Verhaltens geprüft wird. Es ist aber genauso wichtig, Negativ-Tests und Rahmentests durchzuführen, wo der geprüfte Teil konkret auf das Verhalten in einem Grenz- oder Fehlerfall geprüft wird. So kann bereits vielen Fehlern in der Zukunft mit wenig Aufwand vorgebeugt werden.

Ein Unit-Test ist immer ein sog. “White-Box” Test, da der Entwickler bei der Implementation von Unit-Tests den Sourcecode kennt oder ihn einsehen kann.

Bei Unit-Tests in Java ist der Testumfang eines Unit-Tests normalerweise in der Grössenordnung einer Methode oder Funktionalität.

In der Regel lassen sich Unit-Tests automatisieren und können einzeln oder auch in Gruppen (in sog. Test-Suites) lokal (an der eigenen Maschine) oder von einem Continuous-Integration-Server (eine externe Maschine, die dafür sorgt, dass Programmteile sofort getestet und zusammengeführt werden können) sehr schnell durchgeführt werden.

Integration-Tests

Mit Integration-Tests wird sichergestellt, dass verschiedene Programmteile der Anwendung problemlos ineinandergreifen und miteinander harmonieren. So kann beispielsweise die Interaktion mit einer Datenbank oder das Zusammenspiel von verschiedenen Mikroservices getestet werden. Im Gegensatz zu Unit-Tests beschränkt sich diese Test-Art also nicht auf einzelne Methoden oder Funktionen, sondern auf verschiedene Module oder Klassen und deren Zusammenspiel.

Tests dieser Art sind kostspieliger und können auch länger dauern als Unit-Tests, weil dafür mehrere Teile der Anwendung funktionsfähig sein müssen. Dafür können konkretere Aussagen über den generellen Zustand einzelner, ineinandergreifende Funktionalitäten aufgrund der Testergebnisse getätigt werden.

System-Tests

System-Tests gehen noch einmal eine Stufe höher als Integrations-Tests und prüfen eine gesamte Applikation auf spezifische Business-Anforderungen. Zumeist werden diese mithilfe von Tools automatisch ausgeführt und sind im Vergleich zu Integrations-Tests und vor allem Unit-Tests ziemlich aufwändig.

System-Tests bieten, wenn sie richtig geschrieben und gepflegt werden, einen grossen Mehrwert für das Team. Da nicht nur einzelne Funktionen, sondern ganze Anwendungsfälle geprüft werden und die Tests automatisch ausführbar sind, bieten sie einen starken Rückhalt bei der Pflege der Applikation und der Kommunikation bei Erreichung einer Anforderung.

Akzeptanz-Tests

Bei Akzeptanz-Tests wird der Umgang des Benutzers (oder auch andere, externe Programme) mit der Software in einer vollständigen Anwendungsumgebung repliziert. Auf diese Weise wird das ordnungsgemässe Funktionieren von Benutzerabläufen überprüft. Die Szenarien können ganz einfach sein (z.B. Laden einer Website, Anmeldevorgang) oder auch sehr komplex (z.B. E-Mail-Benachrichtigungen, Onlinezahlungen).

Akzeptanz-Tests sind sehr nützlich, aber auch aufwändiger zu erstellen und in automatisierter Form unter Umständen schwer zu verwalten. Daher empfiehlt es sich grundsätzlich, Akzeptanz-Tests manuell auf Basis der Use-Cases, die man für eine Applikation hat, zu testen. Viele Teams oder Organisations-Einheiten haben ein dediziertes Testing-Team, welches sich ausschliesslich um das Testen von Applikationen und den Umgang mit gefundenen Fehlern kümmert.

Wichtig anzumerken ist ebenfalls, dass Akzeptanz-Tests auch nicht-funktionale Anforderungen abdecken. Während Unit-Tests hauptsächlich funktionale Anforderungen wie beispielsweise eine Anmeldefunktion geprüft werden, werden mit Akzeptanz-Tests auch nicht-funktionale Anforderungen wie bspw. die Latenz von Anfragen geprüft.


JUnit

Zur Implementation von Unit-Tests steht in Java das Framework JUnit zur Verfügung. Die aktuellste Version ist 5.9.0. Dies ändert aber stetig, da das Produkt laufend weiterentwickelt wird. Vielfach ist in Produkten und Projekten auch JUnit 4 im Einsatz. In diesem Modul wird jedoch nur die aktuellste Version von JUnit behandelt. Wir schreiben Unit-Tests also mit JUnit 5.

Wie ist JUnit 5 aufgebaut?

Das Framework besteht aus folgenden Teilen:

TeilVerwendung
JUnit PlattformGrundlage zur Einführung von Testframeworks in die JVM. Definition der Test-Engine zur Entwicklung von Testframeworks auf der jeweiligen Plattform. Plattform-Konsole zum Starten der Plattform. Kurz gesagt: Plattform zur Ausführung von Unit-Tests
JUnit JupiterProgrammiermodell zur Implementation von Unit-Tests
JUnit VintageErmöglicht die Ausführung von Tests, die mit JUnit 3 oder JUnit 4 geschrieben wurden

Wo kann ich das Framework herunterladen?

Damit wir nun Unit-Tests implementieren können benötigen wir zuerst die Bibliotheken von JUnit 5, dies wird in diesem Abschnitt beschrieben.

Seit JUnit 5.4 reicht die folgende Abhängigket für das Project Object Model (pom.xml):

1
2
3
4
5
6
7
8
<dependencies>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.9.0</version>
    <scope>test</scope>
  </dependency>
</dependencies>

Füge diese Abhängigkeit in dein pom.xml ein. Danach besitzt du alle Bibliotheken, die zur Implementation von Unit-Tests notwendig sind. Die Erklärung zu Maven und der Datei pom.xml kommen später.

Wenn du noch keine pom.xml Datei hast, ist hier noch ein komplettes Beispiel aufgeführt:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>ch.sbb.tafy.examples</groupId>
    <artifactId>maventesting</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <!-- Plattform -->
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.12.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

</project>

Speichere diese Datei im Basisverzeichnis deines Projekts ab.

Ordnerstruktur anlegen

Um Unit-Tests implementieren zu können benötigen wir grundsätzlich die folgende Ordnerstruktur im IntelliJ IDEA.

Ordnerstruktur IntelliJ IDEA

Falls dein Projekt kein Maven-Projekt ist, kannst du die Verzeichnisse einfach manuell anlegen. Mit einem Rechtsklick auf die Verzeichnisse, kannst du sie mit dem Befehl “Mark Directory as” aus dem Kontext-Menü wie folgt markieren:

VerzeichnisMarkierungZweck
src/main/javaSources RootSource Code deiner Applikation
src/main/resourcesResources RootRessourcen deiner Applikation, die nicht Programmcode sind
src/test/javaTest Sources RootSource Code deiner Unit-Tests
src/test/resourcesTest Resources RootRessourcen deiner Unit-Tests, die nicht Programmcode sind

Diese Ordnerstruktur wurde ursprünglich vom Projektmanagement-Tool Gradle “erfunden” und dann von Maven übernommen. Stand heute ist dies die Standard-Ordnerstruktur innerhalb von Java-Projekten.

Implementation von Unit-Tests an einem Beispiel

Nach all den Vorbereitungen sind wir nun bereit Unit-Tests zu implementieren. Der folgende Abschnitt beschreibt die Grundlagen für das Schreiben von Unit-Tests.

Das JUnit-Framework

  • nutzt Assertions, um Resultate innerhalb eines Tests zu überprüfen
  • nutzt Annotationen, um Testfälle zu finden und durchzuführen

Erklärung

Beispiel einer Unit-Test Implementation anhand eines einfachen Beispiels.

Hinweis im Beispielcode wird folgendes Namensschema für Tests verwendet:

1
2
3
public void given_when_then() {
        ...
}

Wobei:

  • given die Ausgangslage definiert (z.B. twoIntegers oder givenTwoIntegers)
  • when ist der Name der Methode, welche getestet wird (z.B. whenAdd oder add)
  • then ist das Ergebnis, welche erwartet wird (z.B. thenSumIsCorrect oder sumCorrect)

Source-Code

1
2
3
4
5
6
7
package ch.sbb.talentfactory.calculator;

public class Calculator {
    public int add(int i1, int i2) {
        return i1 + i2;
    }
}

Test-Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package ch.sbb.talentfactory.calculator;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    private Calculator uut = new Calculator();

    @Test
    void givenTwoIntegers_whenAdd_thenSumIsCorrect() {
      // prepare test data
      int i1 = 5;
      int i2 = 9;
      // call method
      int result = this.uut.add(i1, i2);
      // verify
      assertEquals(14, result);
    }
}

Der Unit-Test befindet sich im gleichen Package wie die zu testende Klasse. Innerhalb der weiter oben genannten Ordnerstruktur ist die Test-Klasse aber nicht am gleichen Ort abgelegt! Innerhalb des Unit-Tests wird zuerst eine Instanz der zu testenden Klasse angelegt. Die Bezeichnung für diese Instanz lautet normalerweise UUT, dies steht für “Unit Under Test”. Für jeden Test einer der Methoden aus dem UUT wird anschliessend eine Test-Methode implementiert. Diese Methoden sind mit @Test zu annotieren, so werden sie anschliessend vom Test-Framework als eigenständiger Test erkannt und ausgeführt. Ein Unit-Test kann beliebig viele Testmethoden enthalten. Grundsätzlich reichen aber je nach Funktionalität ein paar wenige Tests aus, um die ganze Funktionalität einer Methode zu überprüfen. Innerhalb der Test-Methoden implementieren wir dann “normalen” Programmcode, welcher den Code aus dem UUT “überprüft”. In unserem Beispiel setzen wir zwei Variablen und rufen damit die zu testende Methode auf. Mit einer Assertion vergleichen wir dann einen erwarteten Wert mit dem von der Methode zurückgelieferten Resultat. Beachte, dass der erwartete Wert in der Assertion immer an erster Stelle stehen muss. Wenn die beiden Werte identisch sind, dann ist der Unit-Test erfolgreich.

Vorgehen beim Schreiben von Unit-Tests

Grundsätzlich sollte die AAA-Methode angewendet werden, sie ist auch im Beispiel oben ersichtlich. AAA steht für “Arrange”, “Act” und “Assert”. “Arrange” steht dabei für die Vorbereitung des Tests, “Act” ist die eigentliche Durchführung und mit “Assert” werden die Resultate des Tests überprüft.

Grenzwerte austesten

Vielfach sind gewisse Funktionen in einer Applikation so implementiert, dass sie sich mit wenigen Unit-Tests komplett testen lassen. Dazu ein kleines Beispiel: gegeben ist ein Rechteck mit bestimmten Koordinaten (Ecke oben links) und einer bestimmten Grösse (Höhe und Breite).
Eine Methode innerhalb des Rechtecks dient dazu herauszufinden, ob eine bestimmte Koordinate inner- oder ausserhalb des Rechtecks liegt. Punkte, die auf dem Rand zu liegen kommen gelten in diesem Sinne nicht als innerhalb des Rechtecks.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package ch.sbb.talentfactory.rectangle;

public class Rectangle {
    private int top;
    private int left;
    private int width;
    private int height;

    public Rectangle(int top, int left, int width, int height) {
        this.top = top;
        this.left = left;
        this.width = width;
        this.height= height;
    }

    public boolean isInside(int x, int y) {
        if (x > left && x < left + width) {
            if (y > top && y < top + height) {
                return true;
            }
        }
        return false;
    }
}

Damit keine Verwirrung entsteht, hier das verwendete Koordinatensystem. Koordinatensystem

Wie wir in der Implementation sehen können, gibt es hier vier verschiedene Bedingungen. Das Ziel des Tests muss es also sein, dass wir alle diese Bedingungen überprüfen. Wenn immer möglich, sollten alle möglichen Kombinationen getestet werden. Nur so kann sichergestellt werden, dass die Methode wie gewünscht funktioniert. Aufgrund der AND-Verknüpfung werden die zweiten Bedingungen der jeweiligen Statements nicht mehr ausgewertet. Damit müssen die folgenden Kombinationen durch einen Unit-Test abgedeckt werden

Bedingungx > leftx < left + widthy > topy < top + heightResultat
Variante 1FalseFalse
Variante 2TrueFalseFalse
Variante 3TrueTrueFalseFalse
Variante 4TrueTrueTrueFalseFalse
Variante 5TrueTrueTrueTrueTrue

Dies bedeutet wir implementieren fünf Unit-Tests, um die Methode vollständig abzudecken.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package ch.sbb.talentfactory.rectangle;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class RectangleTest {

	// I know this is a square ;-)
    private Rectangle uut = new Rectangle(0, 0, 10, 10);

    @Test
    void givenPointLeftOfRectangle_whenIsInsideCheck_thenReturnsFalse() {
      assertFalse(this.uut.isInside(-1, 5));
    }

    @Test
    void givenPointRightOfRectangle_whenIsInsideCheck_thenReturnsFalse() {
      assertFalse(this.uut.isInside(11, 5));
    }

    @Test
    void givenPointAboveRectangle_whenIsInsideCheck_thenReturnsFalse() {
      assertFalse(this.uut.isInside(5, -1));
    }

    @Test
    void givenPointBelowRectangle_whenIsInsideCheck_thenReturnsFalse() {
      assertFalse(this.uut.isInside(5, 11));
    }

    @Test
    void givenPointInsideRectangle_whenIsInsideCheck_thenReturnsTrue() {
      assertTrue(this.uut.isInside(5, 5));
    }

}

Im IntelliJ gibt es wie bei der Ausführung einer Applikation auch die Möglichkeit einen Unit-Test zu debuggen. Zusätzlich können wir die Testabdeckung anschauen, wenn wir den Unit-Test mit “Coverage” durchlaufen lassen. Wenn der Test erfolgreich durchgelaufen ist, dann kann die getestete Klasse geöffnet werden.

Klasse mit Coverage

Die grünen Balken auf der linken Seite zeigen die Testabdeckung an. In diesem Fall sind sämtliche Zeilen durch einen Test durchlaufen worden.

Da der Unit-Test nun alle Möglichkeiten der Methode abdeckt, kann ein einfaches Refactoring durchgeführt werden. In unserem Fall kann die Methode wie folgt vereinfacht werden:

1
2
3
public boolean isInside(int x, int y) {
	return x > left && x &lt; left + width && y &gt; top && y < top + height;
}

Der Test kann dann beliebig oft erneut durchgeführt werden, um das Refactoring zu überprüfen.

Annotationen von JUnit5

Die folgende Tabelle zeigt eine Übersicht über die wichtigsten Annotationen von JUnit 5. Mehr Informationen zu den jeweiligen Annotationen finden sich in den nächsten Kapiteln.

AnnotationBeschreibung
@TestBezeichnet einen Test
@ParameterizedTestBezeichnet einen parametrisierten Test
@RepeatedTestBezeichnet einen sich wiederholenden Test
@DisplayNameNamensgebung für Testklassen und -methoden
@DisabledMöglichkeit eine Testklasse oder -methode nicht ausführen zu lassen
@TestMethodOrder
@Order
Ausführungsreihenfolge der Tests bestimmen.
@BeforeAll
@BeforeEach
@AfterAll
@AfterEach
Initialiserungen und Aufräumarbeiten vor und nach Unit-Tests

Parametrisierte Unit-Tests

Der oben gezeigte Unit-Test ist ein typisches Beispiel für einen Test, der mit vielen unterschiedlichen Parametern durchlaufen werden sollte. Wenn wir den Test parametrisieren, können wir die Test-Methode wiederverwenden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package ch.sbb.talentfactory.rectangle;

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class RectangleTest {

    private Rectangle uut = new Rectangle(0, 0, 10, 10);

    @ParameterizedTest
    @CsvSource({
            "-1, 5, false",
            "11, 5, false",
            "5, -1, false",
            "5, 11, false",
            "5, 5, true"
    })
    void givenPoint_whenIsInside_thenReturnsExpectedResult(int x, int y, boolean expectedResult) {
      assertEquals(expectedResult, this.uut.isInside(x, y));
    }
}

Mit @CsvSource können mehrere Parameter mit Komma getrennt angegeben werden. Die Anzahl (im Beispiel 3) muss mit den Parameter der Testklasse übereinstimmen.

Ein simpleres Beispiel ist hier zu sehen:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package ch.sbb.talentfactory.calculator;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    private Calculator uut = new Calculator();

    @ParameterizedTest
    @ValueSource(ints = { 1, 2, 3, 4, 5, 6, 7, 8, 9 })
    public void givenOperand_whenAddFive_thenSumIsCorrect(int operand1) {
        // call method
        int result = this.uut.add(operand1, 5);

        // verify
        assertEquals(operand1 + 5, result);
    }
}

Der gezeigte Test wird so insgesamt neun Mal durchlaufen, wobei der Parameter operand1 jeweils die Werte des angegebenen Arrays durchläuft.

Wiederholende Unit-Tests

Unit-Tests können mehrmals hintereinander ausgeführt werden, dabei wird die annotierte Test-Methode einfach mehrfach aufgerufen. Die Anzahl Aufrufe wird durch den Parameter in der Annotation bestimmt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package ch.sbb.talentfactory.calculator;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    private Calculator uut = new Calculator();

    @RepeatedTest(10)
    public void givenRandomIntegers_whenAdd_thenSumIsCorrect() {
        // prepare test data
		Random random = new Random();
        int i1 = random.nextInt(100);
        int i2 = random.nextInt(100);

        // call method
        int result = this.uut.add(i1, i2);

        // verify
        assertEquals(i1 + i2, result);
    }
}

Repetierende Tests können wie oben gezeigt benutzt werden, um beispielsweise mit generierten Zufallszahlen bestimmte Funktionen zu überprüfen.

Display Names

Testklassen und -methoden können mit der Annotation @DisplayName beliebig umbenannt werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package ch.sbb.talentfactory.calculator;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

@DisplayName("Special test for calculator")
public class CalculatorTest {
    private Calculator uut = new Calculator();

    @Test
    @DisplayName("Ultimate addition test")
    public void givenTwoIntegers_whenAdd_thenSumIsCorrect() {
        // prepare test data
        int i1 = 5;
        int i2 = 9;

        // call method
        int result = this.uut.add(i1, i2);

        // verify
        assertEquals(14, result);
    }
}

Der angegebene Name erscheint dann in der Testauswertung.

Tests ausschalten

Testklassen und -methoden können mit der Annotation @Disabled aus den Testläufen ausgeschlossen werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
package ch.sbb.talentfactory.calculator;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

@Disabled("Test disabled until calculator is finished")
public class CalculatorTest {
    private Calculator uut = new Calculator();

    @Test
    public void givenTwoIntegers_whenAdd_thenSumIsCorrect() {
        // prepare test data
        int i1 = 5;
        int i2 = 9;

        // call method
        int result = this.uut.add(i1, i2);

        // verify
        assertEquals(14, result);
    }
}

Reihenfolge der Ausführung

Die Reihenfolge von Tests bei der Ausführung kann durch die Verwendung von @TestMethodOrder und @Order bestimmt werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package ch.sbb.talentfactory.calculator;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

@TestMethodOrder(OrderAnnotation.class)
public class CalculatorTest {
    private Calculator uut = new Calculator();

    @Test
    @Order(1)
    public void givenTwoIntegers_whenAdd_thenSumIsCorrect() {
        // prepare test data
        int i1 = 5;
        int i2 = 9;

        // call method
        int result = this.uut.add(i1, i2);

        // verify
        assertEquals(14, result);
    }

	@Test
	@Order(2)
	public void givenTwoIntegersWithNegative_whenAdd_thenSumIsCorrect() {
        // prepare test data
        int i1 = -1;
        int i2 = 3;

        // call method
        int result = this.uut.add(i1, i2);

        // verify
        assertEquals(2, result);
    }
}

Die Annotation @Order wird nur verwendet, wenn der Typ der Ausführung OrderAnnation.class ist. Weitere Angaben sind “Alphanumeric” (Sortierung nach Methodenname) und “Random” (Zufällige Ausführungsreihenfolge).

Achtung: Die Reihenfolge der Ausführung von Tests sollte möglichst vermieden werden werden. Ein Unit-Test sollte für sich alleine stehend funktionieren und nicht von anderen Tests abhängen.

Daten initialisieren / aufräumen

Mit den Annotationen @BeforeEach, @AfterEach, @BeforeAll und @AfterAll können bestimmte Initialisierungen und Aufräumarbeiten vor und nach Unit-Tests ausgeführt werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package ch.sbb.talentfactory.calculator;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    private Calculator uut = new Calculator();

	@BeforeAll
	public static void setUpAll() {
		// Diese Methode wird VOR allen Test-Methoden EINMALIG ausgeführt
	}

	@BeforeEach
	public void setUp() {
		// Diese Methode wird VOR jeder Test-Methode ERNEUT ausgeführt
	}

    @Test
    public void givenTwoIntegers_whenAdd_thenSumIsCorrect() {
        // prepare test data
        int i1 = 5;
        int i2 = 9;

        // call method
        int result = this.uut.add(i1, i2);

        // verify
        assertEquals(14, result);
    }

	@AfterEach
	public void tearDown() {
		// Diese Methode wird NACH jeder Test-Methode ERNEUT ausgeführt
	}

	@AfterAll
	public static void tearDownAll() {
		// Diese Methode wird NACH allen Test-Methoden einmalig ausgeführt
	}
}

Verwendung von Providern

Bei der Verwendung von parametrisierten Tests ist es möglich, dem Unit-Test über einen Stream von Argumenten entsprechende Testdaten oder Instanzen von verschiedenen Objekten zukommen zu lassen. Das folgende Beispiel illustriert die Verwendung eines solchen Providers.

Vier Gewinnt Interface

1
2
3
public interface ConnectFourCheck {
    String checkWin(String[][] board);
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class VierGewinntTest {

    private String[][] testBoard = {
        {" ", " ", " ", " ", "O"},
        {" ", " ", " ", "O", "X"},
        {" ", " ", " ", "O", "X"},
        {" ", " ", "O", "X", "X"},
        {" ", " ", " ", " ", "X"}
    };

    // Alle Klassen des Streams (VierGewinntSolution1 und VierGewinntSolution2) implementieren das oben gezeigte Interface
    private static Stream<Arguments> instances() {
        return Stream.of(
            Arguments.of(new VierGewinntSolution1()),
            Arguments.of(new VierGewinntSolution2())
        );
    }

    @ParameterizedTest
    @MethodSource("instances")
    public void givenBoardWithWinningX_whenCheckWin_thenReturnsX(ConnectFourCheck cfc) {
        try {
            String winner = cfc.checkWin(this.testBoard);
            assertEquals("X", winner);
        } catch (Exception e) {
            fail(e);
        }
    }
}

Mit einem solchen Provider kann derselbe Unit-Test für verschiedene Implementation (zum Beispiel eines Interfaces) wiederverwendet werden.


Mockito

Mockito ist ein Framework zum Erstellen und Benutzen von Mocks in Softwaretests. Es bietet die Möglichkeit, Verhalten von noch nicht implementierten Klassen und Methoden rudimentär zu simulieren, um so das zu testende System (trotz fehlender Teile) testen zu können. Mockito ermöglicht auch andere Systeme/Services zu simulieren, welche z.B. von einem anderen Team entwickelt werden wie auch solche, deren echtes Verhalten zum Testzweck nicht interessiert oder sogar nicht erwünscht ist (man will aber z.B. wissen, dass diese Systeme/Services vom getesteten Code angesprochen worden sind).

Mockito kann in Unit-Tests wie auch in Integrations-Tests verwendet werden.

Abhängigkeiten einbinden mit Maven

Die entsprechende Abhängigkeit für das Project Object Model (pom.xml) ist:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<dependencies>
  <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-core</artifactId>
    <version>5.3.1</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.mockito</groupId>
    <artifactId>mockito-junit-jupiter</artifactId>
    <version>5.3.1</version>
    <scope>test</scope>
  </dependency>
</dependencies>

Mockito Begriffe

Mock

Mocks sind ein vollständiger Ersatz für Objekte, Services usw., von denen der zu testende Code abhängt, um seine Kernlogik zu testen. Ein Mock kann so programmiert werden, dass es eine angegebene Ausgabe zurückgibt, wenn eine Methode des Mocks aufgerufen wird.

Mockito bietet eine Standardimplementierung für alle Methoden eines Mocks. Das bedeutet, dass beim Aufrufen einer Methode eines Mocks, nicht der “echte” Code der Methode aufgerufen wird, sondern es wird ein von Mockito vordefinierter Wert zurückgeliefert (abhängig vom Rückgabewert-Typ der Methode).

Es gibt Situationen, in denen es nützlich sein kann, bestimmte Werte aus einer Methode zurückzuliefern statt die Mockito-Standardwerte. In diesen Situationen kann ein gewünschter Rückgabewert vorkonfiguriert werden, so dass beim Aufruf der Methode, dieser Wert zurückgeliefert wird.

Spy

Ein Spy (‘Spion’) ist im Wesentlichen ein Wrapper für eine “echte” Instanz eines gemockten Objekts. Dies bedeutet, dass eine neue Instanz des Objektes erforderlich ist und dann ein Spy darüber hinzugefügt wird.

Standardmässig leiten Spies Methodenaufrufe an die “echten” Methoden des Objekts weiter. Das ist auch der Hauptunterschied zwischen Spies und Mocks. Letztere überschreiben den “echten” Methoden-Code.

Spies bieten aber auch die Möglichkeit, bestimmte Methoden als Mock-Methoden vorzukonfigurieren. In solchen Fällen, wird der Methodenaufruf nicht auf den “echten” Code weitergeleitet, sondern es wird, wie beim Mock, der vorgegebene Rückgabewert zurückgeliefert.

Mockito Annotationen

Damit die Mockito-Annotationen innerhalb eines JUnit-Tests verwendet werden könnten, müssen sie zuerst eingeschaltet werden. Eine Möglichkeit dies zu tun ist, die Unit-Test-Klasse mit @ExtendWith zu annotieren und als Parameter den Wert MockitoExtension.class anzugeben:

1
2
3
4
@ExtendWith(MockitoExtension.class)
public class MyUnitTest {
    //TODO write tests
}

Die folgende Tabelle zeigt eine Übersicht über die wichtigsten Annotationen von Mockito:

AnnotationBeschreibung
@MockMock-Objekte erzeugen lassen
@InjectMockMarkiert ein Feld, welches mit Mocks initiiert wird
@SpySpy-Objekte erzeugen lassen
@CaptorArgumentCaptor Objekte erzeugen lassen

Wie diese Annotationen verwendet werden, wird in den folgenden Kapiteln gezeigt.

@Mock Annotation

Diese Annotation wird dazu verwendet, um Mock-Objekte komplett von Mockito erzeugen zu lassen. Das heisst, die gesamte Mock-Funktionalität wird von Mockito zur Verfügung gestellt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@ExtendWith(MockitoExtension.class)
public class MyUnitTest {
    @Mock
    private List<String> mockedList; // hier wird eine Liste von Strings gemockt.
                                     // Mockito stellt eine rudimentäre Umsetzung für JEDE Methode der Liste zur Verfügung

    @Test
    public void givenMockedList_whenAddElement_thenSizeRemainsZero() {
        mockedList.add("one");

        assertEquals(0, mockedList.size());
    }
}

Was mit einem Mock gemacht werden kann und wie der Mock vorkonfiguriert werden kann, wird in einem späteren Kapitel erklärt.

@InjectMocks Annotation

Wenn eine Klasse ein Objekt-Feld beinhaltet, kann Mockito dieses Feld mit einem Mock initiieren. Damit es funktioniert, muss das Feld entweder via Konstruktor, via Setter oder via Property-Injection initialisiert werden.

Im folgenden Beispiel, hat die Klasse MyService ein Feld vom Typ DataService, welches mittels Konstruktor initialisiert werden kann:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import java.util.List;

public class MyService {
  private final DataService dataService;

  public MyService(DataService dataService) {
    this.dataService = dataService;
  }

  public int processData(List<Integer> numbers) {
    return dataService.sum(numbers);
  }
}
1
2
3
4
5
import java.util.List;

public interface DataService {
    int sum(List<Integer> numbers);
}

Im Test, wird ein Mock für ein DataService erstellt und mit der Annotation @InjectMocks, via Konstruktor-Initialisierung in dem MyService-Objekt injektiert (der Konstruktor muss also nicht noch dazu aufgerufen werden):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@ExtendWith(MockitoExtension.class)
public class MyServiceTest {
  @Mock
  private DataService dataService; // hier wird ein DataService gemockt.

  @InjectMocks
  private MyService myService;  // der Mock von DataService wird in die MyService-Instanz "injiziiert", das heisst
                                // überall im DataService-Objekt, wo der DataService verwendet wird, wird der Mock zum Zug kommen!

  @Test
  public void givenListOfNumbers_whenProcessData_thenReturnsSum() {
    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

    Mockito.when(dataService.sum(numbers)).thenReturn(15);

    int result = myService.processData(numbers);

    assertEquals(15, result);
  }
}

@Spy Annotation

Ein Spy wird auf einem “echten” Objekt erzeugt. Dieser Spy leitet, sofern nichts anderes konfiguriert wurde, alle Methodenaufrufe an das echte Objekt weiter. Mit Hilfe der Mockito-Methoden, kann jedoch definiert werden, dass bestimmte Methoden “umgeleitet” werden und eine andere Umsetzung dafür angewendet wird.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;


@ExtendWith(MockitoExtension.class)
public class MyUnitTest {
  @Spy
  private List<String> spiedList;  // Ein Spy über eine Liste. Wenn nichts anders konfiguriert wird
                                    // werden die "echte" Listen-Methoden aufgerufen, wenn der Spy verwendet wird.

  @Captor
  private ArgumentCaptor<String> stringCaptor; // stringCaptor wird ein Argument vom Typ String "fangen"

  @Test
  public void givenSpiedList_whenAddElement_thenCaptorCapturesValue() {
    spiedList.add("one");
    Mockito.verify(spiedList).add(stringCaptor.capture()); // während der Prüfung wird das Argument
    // für die Methode add() gefangen
    // und im stringCaptor aufbewahrt

    assertEquals("one", stringCaptor.getValue()); // mit getValue() kann das gefangene Argument inspiziert werden
  }
}

⚠️ ACHTUNG ⚠️

Für Stubbing in @Spy muss immer die Notation Mockito.doReturn().when().size(); verwendet werden. Mit Mockito.when().thenReturn() wird es nicht funktionieren. Grund dafür ist, dass wenn Mockito.doReturn().when().size(); verwendet wird, in jedem Fall das gegebene Return durchgeführt wird. Wenn bei Mockito.when().thenReturn() zum Beispiel eine Exception auftritt, wird nicht das richtige Resultat zurück gegeben.

Wie ein Spy verwendet werden kann, um nur einige Methoden umzuleiten, wird in einem späteren Kapitel erklärt.

@Captor Annotation

Ein ArgumentCaptor kann Argumente einer Methode “fangen” damit diese danach inspiziert werden könnten.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;


@ExtendWith(MockitoExtension.class)
public class MyUnitTest {
  @Mock
  private List<String> mockedList;

  @Captor
  private ArgumentCaptor<String> stringCaptor; // stringCaptor wird ein Argument vom Typ String "fangen"

  @Test
  public void givenMockedList_whenAddElement_thenCaptorCapturesValue() {
    mockedList.add("one");
    Mockito.verify(mockedList).add(stringCaptor.capture()); // während der Prüfung wird das Argument
                                                            // für die Methode add() gefangen
                                                            // und im stringCaptor aufbewahrt

    assertEquals("one", stringCaptor.getValue()); // mit getValue() kann das gefangene Argument inspiziert werden
  }
}

Mockito statische Methoden

Mockito stellt mehrere statische Methoden zur Verfügung, welche das Konfigurieren von Mocks und Spies wie auch deren Überwachung ermöglichen. In diesem Kapitel werden die wichtigsten Methoden anhand von Beispielen erklärt.

Mockito.when kombiniert mit Mockito.thenReturn

Die Mockito.when Methode kombiniert mit der Methode Mockito.thenReturn ermöglicht es, die Standardimplementierung einer Methode für ein gegebenen Mock zu überschreiben. Dasselbe kann auch mit der Kombination Mockito.doReturn und danach when erreicht werden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@ExtendWith(MockitoExtension.class)
public class MyUnitTest {
    @Mock
    private List<String> mockedList; // eine Mock-Liste, mit Mockitos-Standardimplementierung für alle Methoden

    @Test
    public void givenMockedList_whenSizeIsOverridden_thenReturnsConfiguredValues() {
        assertEquals(0, mockedList.size()); // die Mockito-Standardimplementierung für "size()"
                                            // liefert immer 0 zurück

        Mockito.when(mockedList.size()).thenReturn(10); // hier wird die Standardimplementierung der Methode
                                                        // size() auf dem Mock-Objekt überschrieben,
                                                        // sodass immer der Wert 10 zurückgeliefert wird.
        assertEquals(10, mockedList.size());

        Mockito.doReturn(20).when(mockedList).size(); // auch hier wird die Standardimplementierung der Methode
                                                      // size() auf dem Mock-Objekt überschrieben,
                                                      // diesmal mit dem Wert 20.
        assertEquals(20, mockedList.size());
    }
}

Mockito.verify

Die Mockito.verify Methode prüft, ob eine Interaktion mit dem Mock/Spy-Objekt stattgefunden hat. Geprüft werden kann unter anderem folgendes (weitere Prüfungen werden hier anhand von Beispielen erklärt):

  • Es gab keine Interaktion mit dem Mock/Spy
  • Es gab eine Interaktion mit dem Mock/Spy
  • Es gab eine gewisse Anzahl an Interaktionen mit dem Mock/Spy
  • Es gab mindestens eine gewisse Anzahl an Interaktionen mit dem Mock/Spy
  • Es gab nicht mehr als eine gewisse Anzahl an Interaktionen mit dem Mock/Spy

Interaktion in diesem Sinn kann entweder eine Interaktion mit dem Objekt oder mit einer seiner Methoden sein.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;

import static org.mockito.Mockito.*;


@ExtendWith(MockitoExtension.class)
public class MyUnitTest {
  @Mock
  private List<String> mockedList; // eine Mock-Liste, mit Mockitos-Standardimplementierung für alle Methoden

  @Spy
  private List<String> spiedList; // ein Spy über eine Liste.

  @Test
  public void givenSpiedList_whenAddAndClear_thenVerifyInteractions() {
    spiedList.add("one"); // hier wird die "echte" add Methode einer Liste aufgerufen!

    verify(spiedList).add("one"); // prüfe, ob die "add" Methode mit dem Parameter "one" auf dem spyList aufgerufen wurde
    verify(spiedList, never()).size(); // prüfe, ob die size() Methode nie aufgerufen wurde

    spiedList.clear();
    spiedList.clear();
    verify(spiedList, times(2)).clear(); // prüfe, ob die clear() Methode genau 2 Mal aufgerufen wurde
  }

  @Test
  public void givenMockedList_whenSizeCalled_thenVerifyInteractionCount() {
    Mockito.verifyNoInteractions(mockedList); // bis hier gab es keine Interaktionen mit dem mockedList Objekt

    mockedList.size();
    mockedList.size();
    mockedList.size();
    mockedList.size();

    verify(mockedList, atLeast(1)).size(); // prüfe, ob die size() Methode mindestens einmal aufgerufen wurde
    verify(mockedList, atMost(5)).size(); // prüfe, ob die size() Methode nicht mehr als 5 Mal aufgerufen wurde
  }
}

task1 Jetzt bist du dran. Löse bitte die Aufgaben in den Labs.