Softwaretechnik, 03. Testen als Qualitätsmanagement

Material für die Softwaretechnik Vorlesung, Bernhard Rumpe

Testen

Das Testen ist ein integraler Bestandteil der Softwareentwicklung und soll im Rahmen des Software-Qualitätsmanagements dabei helfen, Fehler frühzeitig zu erkennen. Komplexität, Zeitdruck oder Missverständnisse in der Kommunikation können leicht zu fehlerhaftem Code führen. Bleibt dieser unentdeckt, werden oft langwierige Fehlersuchen und aufwendige Reparaturen nötig. Es können allerdings auch finanzielle Schäden entstehen oder im Extremfall Unfälle mit Personenschaden. Besonders problematisch ist es, wenn sich Fehler erst im Betrieb als Fehlfunktion zeigen.

„Program testing can be used to show the presence of bugs, but never to show their absence!“ - Edsger W. Dijkstra (1970)

Tests haben mehrere grundlegende Aufgaben: Sie decken Fehlerzustände und deren Auswirkungen auf, unterstützen die Verifikation von Anforderungen, präzisieren Schnittstellen und können die Korrektur von Fehlern dokumentieren.

Die immer gleiche Testausführung wird zur Entdeckung neuer Fehler mit der Zeit weniger effektiv. Deshalb sollten Tests kontinuierlich weiterentwickelt werden. Gleichzeitig ist die wiederholte, automatisierte Ausführung trotzdem sinnvoll, um (wieder-)auftretende Fehler zu finden. Gerade in inkrementellen und iterativen Entwicklungsprozessen mit häufigen Codeänderungen zeigen wiederholte Testläufe neue Fehler in bereits getesteten Funktionen - sogenannte Regressionen. Diese Testläufe nennt man daher auch Regressionstests. Durch Testautomatisierung können vorhandene Tests beispielsweise beim Build eines Projekts oder in einer CI/CD-Pipeline ausgeführt und neue Fehlerfälle automatisch gemeldet werden.

Verwendung von JUnit

Das verbreitetste Testframework für Java ist JUnit. Es bietet umfangreiche Möglichkeiten zur Definition und Ausführung von automatisierten Tests und wird von gängigen integrierten Entwicklungsumgebungen (IDEs) nativ oder per Plugin unterstützt. Zusätzlich können Tests auch über ein Command-Line Interface (CLI) ausgeführt werden.

JUnit als CLI in der Kommandozeile

Für die Ausführung von JUnit-Tests über die Kommandozeile wird die JAR-Datei junit-platform-console-standalone benötigt. Sie kann aus dem Maven Central Repository heruntergeladen werden. Bevor Tests ausgeführt werden können, müssen die Java-Dateien des Testobjekts und der Tests kompiliert werden. Da hierbei JUnit-Klassen referenziert werden, müssen diese im Classpath liegen – also an Orten, an denen die JVM nach Klassen sucht.

Der folgende Befehl kompiliert die Dateien Account.java und AccountTest.java in den Ordner out und fügt JUnit über die Option -cp zum Classpath hinzu. (Mehrere Classpath-Einträge werden unter Windows mit ;, unter Linux und macOS mit : getrennt.)

javac -cp junit-platform-console-standalone.jar -d out src/.../Account.java src/.../AccountTest.java

Anschließend können Tests über die CLI gestartet werden. Der folgende Befehl führt die Tests der Klasse AccountTest aus. Es gibt zahlreiche weitere Parameter, um einzelne Klassen, alle Klassen eines Packages oder alle Testklassen im Classpath auszuführen.

java -jar junit-platform-console-standalone.jar --class-path out --select-class de.example.extended.AccountTest

Wichtig: Alle zuvor kompilierten Klassen müssen im Classpath verfügbar sein. Da bei java -jar die JVM-Option -cp ignoriert wird, übergeben Sie den Classpath über die Option --class-path an den JUnit-Launcher.

JUnit mit Gradle und in IDEs

Viele Entwicklungsschritte werden automatisiert, um wiederholte Arbeit zu vermeiden und einen effizienten, einheitlichen Erzeugungsprozess zu erreichen. Für Java-Projekte dominieren die Build-Tools Maven und Gradle; beide unterstützen die automatische Testausführung.

Im Rahmen der Übungen zur Softwaretechnik-Vorlesung verwenden wir Gradle, daher betrachten wir hier ausschließlich dieses Tool. Für Maven finden Sie umfassende Dokumentationen online. Weitere Details zur Build-Automatisierung erhalten Sie in der Vorlesung.

Die Gradle-Konfiguration erfolgt über die Datei build.gradle. Ein minimales Beispiel für ein Projekt sieht so aus:

plugins {
  id 'java'  // <- Plugin für Kompilierung und Testen von Java Code
}

repositories {
  mavenCentral()  // <- Wo soll Gradle nach Abhängigkeiten suchen, hier: Maven Central Repository
}  

Als Nächstes fügen Sie JUnit als Abhängigkeit hinzu. Gradle lädt dann automatisch die benötigte Version. Da JUnit modular aufgebaut ist, können für bestimmte Funktionen zusätzliche Abhängigkeiten erforderlich sein. Die Versionen der einzelnen JUnit-Module werden über die JUnit BOM (junit-bom) einmalig festgelegt und müssen daher nur an einer Stelle gepflegt werden. Der junit-platform-launcher wird von Gradle zur Laufzeit benötigt, um die Tests zu starten.

dependencies {
  testImplementation platform("org.junit:junit-bom:6.1.3")  // <- legt die Version aller JUnit-Module fest
  testImplementation "org.junit.jupiter:junit-jupiter"      // <- JUnit Jupiter (Tests schreiben)
  testRuntimeOnly "org.junit.platform:junit-platform-launcher"  // <- Starten der Tests durch Gradle
} 

Gradle steuert den Build-Ablauf über Tasks, die in einer vorgegebenen Reihenfolge aufgerufen werden. Der Task test wird standardmäßig gegen Ende eines Builds ausgeführt. Damit JUnit-Tests laufen, muss der Task so konfiguriert werden, dass Gradle die JUnit-Platform aktiviert:

test {
  useJUnitPlatform()
}

Zusammengesetzt ergibt sich folgende Gradle-Konfiguration:

plugins {
  id 'java'
}

repositories {
  mavenCentral()
}

dependencies {
  testImplementation platform("org.junit:junit-bom:6.1.3")
  testImplementation "org.junit.jupiter:junit-jupiter"
  testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}

test {
  useJUnitPlatform()
}

Mit einer lokalen Gradle-Installation starten Sie die Tests nun, indem Sie folgenden Kommandozeilenbefehl ausführen:

gradle test

Wir empfehlen jedoch den Einsatz einer IDE. Zu den bekanntesten zählen hierbei IntelliJ IDEA und VSCode. Unsere Empfehlung für Java ist IntelliJ IDEA, da diese IDE speziell auf Java-Entwicklung zugeschnitten ist. VSCode ist allgemeiner ausgerichtet und benötigt meist zusätzlichen Konfigurationsaufwand.

JUnit in IntelliJ IDEA

IntelliJ IDEA unterstützt JUnit-Tests ohne zusätzliche Plugins. Sie können Tests auf verschiedene Wege starten:

  • Im Projekt Tool Window per Rechtsklick auf eine Testdatei und Auswahl von “Run Test”. Per Rechtsklick auf einen Ordner und “Run Tests in XYZ” lassen sich alle Tests darin starten.
  • Im Editor per Rechtsklick: Befindet sich der Cursor in einer Testmethode, kann diese einzelne Methode ausgeführt werden; andernfalls werden alle Tests der Datei gestartet.
  • Im Editor zeigt IntelliJ am linken Rand kleine grüne Pfeile neben Testklassen- und Testmethodenzeilen. Nach der ersten Ausführung sehen Sie an dieser Stelle zusätzlich das Ergebnis der letzten Ausführung. Ein Klick auf den Pfeil startet die Tests der Klasse oder einer bestimmten Methode.
  • Bei korrekter Gradle-Konfiguration können Sie Tests auch über den Gradle-Task test starten: Im Gradle Tool Window unter “Verification” den Task test ausführen.

Weiterführende Informationen finden Sie in der Dokumentation und diesem Blog Artikel.

JUnit in VSCode

Für die Java-Entwicklung in VSCode benötigen Sie zunächst das Extension Pack for Java (im Extensions-Tab installierbar). Danach wählen und installieren Sie JUnit Jupiter als Testframework. Testklassen und -methoden können anschließend über die kleinen grünen Pfeile am linken Editorrand ausgeführt werden.

Weiterführende Informationen finden Sie in der Dokumentation.

Einführung des laufenden Beispiels

Im Folgenden orientieren wir uns an dem in der Vorlesung verwendeten Beispiel eines einfachen Banksystems. Zur Demonstration verschiedener JUnit-Funktionen wurde die Implementierung etwas erweitert.

(Achtung: Die Implementierung ist bewusst stark vereinfacht, gezeigte Berechnungen entsprechen nicht der Realität!)

Eine Bank verwaltet eine gewisse Menge von Customers, die banktypische Dienste zur Vermögensverwaltung nutzen. Jeder Customer hat eine ID, Vor- und Nachnamen, ein Geburtsdatum, sowie eine beliebige Anzahl von Accounts.
Ein Account besitzt ebenfalls eine ID, den aktuellen Betrag, einen frei wählbaren Namen sowie einen AccountType. Der AccountType enthält Informationen zur Zinsberechnung, etwa den Zinssatz und ein optionales Zinslimit (maximal zu verzinsender Betrag). Auf einen Account kann Geld eingezahlt (deposit) oder abgehoben (withdraw) werden. Mit dem AccountStatementPrinter lassen sich Kontoauszüge erstellen und als Datei speichern.

Grundlegende JUnit-Funktionen

JUnit-Tests sind einfache Methoden, welche üblicherweise in separaten Testklassen definiert werden. Die Namen von Testklassen enden üblicherweise auf “Test” oder “Case” – das ist eine verbreitete Konvention, aber nicht zwingend.

Wir beginnen mit Tests für die Customer-Klasse und erstellen eine neue Klasse CustomerTest.

public class CustomerTest {}

Der erste Testfall soll prüfen, ob die Getter für den Vor- und Nachnamen korrekte Werte zurückgeben. Wir fügen der Testklasse dazu eine Methode testCustomerName hinzu. Der Methodenname ist frei wählbar und wird von JUnit in der Ergebnisdarstellung verwendet – er sollte daher aussagekräftig sein.

import org.junit.jupiter.api.Test;

public class CustomerTest {
  
  @Test
  public void testCustomerName() {
  }
}

Testmethoden werden mit @Test annotiert. Ohne diese Annotation wird eine Methode nicht als Testfall von JUnit erkannt und somit nicht ausgeführt.

Für den Test erstellen wir einen neuen Customer mit der ID 1 namens “Alice Codewell”, geboren am 07.02.2001. Anschließend verwenden wir assertEquals(), um zwei gleichartige Werte zu vergleichen. Bei Abweichungen schlägt der Test fehl. Wir prüfen hier die Methoden getFirstname() und getLastname(). Zusätzlich gibt es assertNotEquals() für den umgekehrten Fall.

import org.junit.jupiter.api.Test;

import java.time.LocalDate;

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

public class CustomerTest {
  
  @Test
  public void testCustomerName() {
    Customer alice = new Customer(1, "Alice", "Codewell", LocalDate.of(2001, 2, 7));

    assertEquals("Alice", alice.getFirstname());
    assertEquals("Codewell", alice.getLastname());
  }
}

Als nächstes möchten wir die Methoden getAge() und isAdult() testen und erstellen die Methode testCustomerAge(). Wir verwenden erneut “Alice Codewell”. Da Alice 2001 geboren wurde, ist sie volljährig. Mit assertTrue() prüfen wir, ob isAdult() in diesem Fall true zurückgibt. Analog dazu gibt es assertFalse() um zu testen, ob ein Wert korrekterweise zu false auswertet.

import org.junit.jupiter.api.Test;

import java.time.LocalDate;

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

public class CustomerTest {
  
  @Test
  public void testCustomerName() {
    Customer alice = new Customer(1, "Alice", "Codewell", LocalDate.of(2001, 2, 7));
    
    assertEquals("Alice", alice.getFirstname());
    assertEquals("Codewell", alice.getLastname());
  }
  
  @Test
  public void testCustomerAge() {
    Customer alice = new Customer(1, "Alice", "Codewell", LocalDate.of(2001, 2, 7));
    
    assertTrue(alice.isAdult());
    assertFalse(alice.getAge() < 18);
  }
}

Bisher mussten wir in jedem Test einen neuen Customer erstellen, was zu Codeduplikaten führt. Sinnvoller ist, gemeinsame Initialisierungen auszulagern. Eine Methode, üblicherweise setUp genannt und mit @BeforeEach annotiert, wird automatisch vor jedem Test ausgeführt. Wir deklarieren nun die Attribute alice, bob und claire und erzeugen sie in setUp() vor jedem Test. JUnit erzeugt für jede Testmethode eine neue Instanz der Testklasse. Jeder Test arbeitet daher mit frisch erzeugten Customer-Objekten und wird nicht von den Änderungen anderer Tests beeinflusst.

import org.junit.jupiter.api.BeforeEach;

public class CustomerTest {
  
  private Customer alice;
  private Customer bob;
  private Customer claire;
  
  @BeforeEach
  void setUp() {
    alice = new Customer(1, "Alice", "Codewell", LocalDate.of(2001, 2, 7));
    bob = new Customer(2, "Bob", "Gitman", LocalDate.of(1997, 9, 16));
    claire = new Customer(3, "Claire", "Agileton", LocalDate.of(2014, 10, 3));
  }
} 

Manche Objekte sind unveränderlich und müssen daher nicht vor jedem Testfall neu erzeugt werden, zum Beispiel die AccountType-Objekte, die Zinssatz und Zinslimit eines Kontos beschreiben. Sie können einmal für die gesamte Testklasse angelegt werden. Während Methoden mit @BeforeEach vor jedem Testfall ausgeführt werden, laufen Methoden mit @BeforeAll einmal vor der Instanziierung der Testklasse – daher muss die Methode statisch sein, und die Attribute, die sie initialisiert, müssen ebenfalls statisch sein:

import org.junit.jupiter.api.BeforeAll;

public class CustomerTest {
  
  private static AccountType kidsAccountType;      // <- statisch
  private static AccountType savingsAccountType;   // <- statisch
  
  private Customer alice;
  private Customer bob;
  private Customer claire;
  
  @BeforeAll                                       // <- einmalig vor Instanziierung
  static void beforeAll() {                        // <- statisch
    kidsAccountType = new AccountType("EasyStart", 0.02, 500);
    savingsAccountType = new AccountType("Savings", 0.001);
  }
  
  @BeforeEach
  void setUp() {
    alice = new Customer(1, "Alice", "Codewell", LocalDate.of(2001, 2, 7));
    bob = new Customer(2, "Bob", "Gitman", LocalDate.of(1997, 9, 16));
    claire = new Customer(3, "Claire", "Agileton", LocalDate.of(2014, 10, 3));
  }
}

Achtung: Objekte, die von mehreren Tests geteilt werden, sollten unveränderlich sein. Verändert ein Test ein solches Objekt (z.B. die Account-Liste eines statischen Customers), kann das das Ergebnis anderer Tests beeinflussen, und die Tests werden von ihrer Ausführungsreihenfolge abhängig. Im Zweifel sollten Sie @BeforeEach verwenden.

Die Gegenstücke zu @BeforeEach und @BeforeAll sind @AfterEach und @AfterAll. Sie werden nach jedem Test bzw. einmalig nach allen Tests der Klasse ausgeführt und eignen sich zum Aufräumen, z.B. zum Schließen von Datenbankverbindungen. In unseren Tests ist das nicht nötig.

Annotation Ort Bedeutung
@Test nicht-statische Methode Testmethode, die von JUnit ausgeführt wird
@BeforeEach nicht-statische Methode Methode, die vor jedem Testfall ausgeführt wird
@AfterEach nicht-statische Methode Methode, die nach jedem Testfall ausgeführt wird
@BeforeAll statische Methode Methode, die einmalig vor der Instanziierung der Testklasse ausgeführt wird
@AfterAll statische Methode Methode, die einmalig nach Abschluss aller Tests der Testklasse ausgeführt wird

Weitere Informationen finden Sie in der Dokumentation.

Korrekte Nutzung von Assertions

Mit einigen Kniffen werden Fehlermeldungen bei fehlschlagenden Assertions deutlich aussagekräftiger:

  • Beachten Sie die Argumentreihenfolge. Bei Assertions, die zwei Werte vergleichen, sollte das erste Argument immer der erwartete Wert sein. Nur so bleibt die Fehlermeldung korrekt (“expected … but was …”) und eine vertauschte Reihenfolge kann beim Debuggen auf eine falsche Fährte führen.
  • Nutzen Sie benutzerdefinierte Fehlermeldungen. Ein zusätzlicher String am Ende einer Assertion (in JUnit 5 und 6 immer als letztes Argument) kann die Fehlersuche erleichtern:
@Test
public void testAssertions() {
  int x = 1;
  
  assertTrue(x > 10);
  // Ausgabe: AssertionFailedError: expected: <true> but was: <false>
  
  assertTrue(x > 10, String.format("x should be greater than 10, but was %d", x));
  // Ausgabe: AssertionFailedError: x should be greater than 10, but was 1 ==> expected: <true> but was: <false>
}
  • Wählen Sie passende Assertions. Die Wahl der Assertion beeinflusst Lesbarkeit und Aussagekraft der Fehlermeldung:
@Test
public void testAssertions() {
  int x = 42;
  
  assertTrue(x == 5);
  // Ausgabe: AssertionFailedError: expected: <true> but was: <false>
  
  assertEquals(5, x);
  // Ausgabe: AssertionFailedError: expected: <5> but was: <42>
}
  • Vergleichen Sie Gleitkommazahlen (double, float) mit einer erlaubten Abweichung (delta). Durch Rundungsfehler sind berechnete Werte oft nicht exakt gleich dem erwarteten Wert (z.B. ist 0.1 + 0.2 nicht genau 0.3). assertEquals(expected, actual, delta) akzeptiert alle Werte, die höchstens um delta abweichen:
@Test
public void testAssertions() {
  assertEquals(0.3, 0.1 + 0.2);          // schlägt fehl (0.30000000000000004)
  assertEquals(0.3, 0.1 + 0.2, 0.0001);  // korrekt
}

Diese Beispiele verdeutlichen, wie Fehlermeldungen mit minimalem Aufwand hilfreicher gestaltet werden können.

Erweiterte Funktionen

Für die meisten Testfälle reichen die zuvor vorgestellten JUnit-Funktionen aus. JUnit bietet darüber hinaus zahlreiche Methoden und Annotationen, die das Testen komfortabler machen.

Zuvor haben wir assertEquals() verwendet, um Gleichheit zu prüfen. Bei Objekten nutzt JUnit dafür intern deren equals()-Methode. Da diese überschrieben werden kann, ist assertEquals() nicht immer ausreichend, wenn Identität (dasselbe Objekt) geprüft werden soll. Dafür gibt es mit assertSame() und assertNotSame() zwei weitere Assertions, welche stattdessen die Referenzen vergleichen (==). Der folgende Test schlägt absichtlich fehl, weil new String(...) jedes Mal ein neues Objekt erzeugt:

@Test
public void testCustomerAccounts() {
  assertEquals(new String("Codewell"), alice.getLastname()); // korrekt  
  assertSame(new String("Codewell"), alice.getLastname());
  // Ausgabe: AssertionFailedError: expected: java.lang.String@7e19ebf0<Codewell> but was: java.lang.String@2474f125<Codewell>  
}

Neben korrekten Abläufen sollte auch Fehlverhalten getestet werden. Tritt im getesteten Code eine Exception auf, schlägt ein JUnit-Test grundsätzlich fehl. Mit assertThrows() kann jedoch gezielt erwartet werden, dass eine bestimmte Exception auftritt.

Unsere Bank bietet Sparkonten an, die nicht überzogen werden können. Fällt der Kontostand durch eine Abbuchung ins Negative, wird eine AmountNotCoveredException geworfen. Den Kontotyp savingsAccountType haben wir dafür bereits in @BeforeAll angelegt:

@Test
public void testCustomerAccount() {
  // Erzeuge neues Sparkonto  
  Account savingAccount = new Account(1, savingsAccountType);
  alice.addAccount(savingAccount);
  
  assertEquals(1, alice.getAccounts().size());
  
  // Konto ist zu Beginn leer  
  assertEquals(0, savingAccount.getBalance());
  
  // Konto kann nicht überzogen werden, beim Abheben soll eine AmountNotCoveredException geworfen werden  
  assertThrows(AmountNotCoveredException.class, () -> savingAccount.withdraw(60));
}

Unabhängige Aspekte sollten normalerweise nicht im selben Test geprüft werden, sondern jeweils in einem eigenen Test (ein Aspekt pro Test). So zeigt jeder fehlgeschlagene Test genau, welcher Aspekt fehlerhaft ist, und ein Fehler verdeckt keine weiteren. Werden in einem Test dennoch mehrere Assertions verwendet, gilt Folgendes:

Ein Test schlägt ab der ersten fehlschlagenden Assertion fehl. Deckt ein Testfall mehrere unabhängige Aspekte ab, werden nach einer gescheiterten Assertion die weiteren nicht mehr ausgeführt. Im ersten Testfall sind Vor- und Nachname unabhängig. Schlägt die Assertion für den Vornamen fehl, wird der Nachname gar nicht mehr geprüft (auch dieser Test schlägt absichtlich fehl):

@Test
public void testCustomerName2() {
  assertEquals("Bob", alice.getFirstname());
  // Ausgabe: AssertionFailedError: expected: <Bob> but was: <Alice>
  
  assertEquals("Gitman", alice.getLastname()); // wird nicht ausgeführt
}

Mehrere Assertions können daher mit assertAll() gruppiert werden. Die darin übergebenen Funktionen werden unabhängig voneinander ausgeführt; am Ende werden alle fehlgeschlagenen Assertions gesammelt gemeldet.

@Test
public void testCustomerName3() {
  assertAll("testNameParts", 
      () -> assertEquals("Bob", alice.getFirstname()),
      () -> assertEquals("Gitman", alice.getLastname())
  );
  // Ausgabe:
  // MultipleFailuresError: testNameParts (2 failures)
  //    AssertionFailedError: expected: <Bob> but was: <Alice>
  //    AssertionFailedError: expected: <Gitman> but was: <Codewell>
}

Wenn der Erfolg eines Tests von komplexen Bedingungen abhängt, die sich nicht angemessen mit vorhandenen Assertions ausdrücken lassen, kann fail() genutzt werden. Ein Aufruf lässt den Test sofort fehlschlagen, optional mit eigener Fehlermeldung.

if (!complexCondition(result)) {
  fail("Result violates the expected invariant: " + result);
}

Beim Testen von Dateisystemzugriffen (z.B. Dateierzeugung oder -modifikation) ist Vorsicht geboten: Erzeugt ein Test beim ersten Durchlauf eine Datei, kann ihr Vorhandensein im zweiten Durchlauf das Verhalten beeinflussen. Sinnvoll ist es daher, schreibende Aktionen in einem isolierten, test-spezifischen Ordner auszuführen. Dafür kann mit @TempDir eine Variable vom Typ File oder Path annotiert werden. JUnit erzeugt bei der Testausführung automatisch einen temporären, leeren Ordner, weist der Variable ein entsprechendes Objekt zu und löscht den Ordner nach dem Test wieder. Die Annotation kann auf Methodenparameter oder Klassenattribute angewendet werden.

Für unsere Bank schreibt die Methode printStatementToFile der Klasse AccountStatementPrinter einen Kontoauszug in eine Datei. Der folgende Test prüft, ob die Datei nach dem Aufruf existiert und den erwarteten Inhalt hat. Damit frühere Läufe die Prüfung nicht verfälschen, nutzen wir einen temporären Ordner. Dazu annotieren wir einen Parameter der Testmethode. Der temporäre Ordner steht dann nur innerhalb dieser Testmethode zur Verfügung:

public class AccountStatementPrinterTest {
  
  @Test
  public void testPrintStatementToFile(
      @TempDir Path tempDir) throws IOException {   // <- zeigt bei Testausführung automatisch auf einen temporären, leeren Ordner
    // Erstellen eines Customers  
    Customer alice = new Customer(1, "Alice", "Codewell", LocalDate.of(2001, 2, 7));
    
    // Erstellen eines neuen Accounts  
    AccountType savingsAccountType = new AccountType("Savings", 0.001);
    Account account = new Account(1, savingsAccountType, "Mein Sparbuch");
    alice.addAccount(account);
    account.deposit(1234.5); // Setze das Guthaben auf 1234,50 Euro  
    
    // Zielpfad für die Ausgabedatei  
    Path targetPath = tempDir.resolve("account_statement.txt");
    
    // Aufruf der Methode zum Drucken des Kontoauszugs in die Datei  
    AccountStatementPrinter.printStatementToFile(account, targetPath);
    
    // Überprüfen, ob die Datei erstellt wurde  
    assertTrue(Files.exists(targetPath));
    
    // Überprüfen des Inhalts (zeilenweise, damit der Zeilenseparator des
    // Betriebssystems keine Rolle spielt)  
    List<String> expectedLines = List.of(
        "==== ACCOUNT STATEMENT ====",
        "Customer: Alice Codewell (1)",
        "Account: Mein Sparbuch (1)",
        "==== ACCOUNT BALANCE ====",
        ">> 1.234,50€"
    );
    assertEquals(expectedLines, Files.readAllLines(targetPath));
  }
}

Alternativ kann @TempDir auch für ein Attribut der Testklasse verwendet werden. Der temporäre Ordner wird dann für jede Testmethode neu erzeugt und steht in allen Methoden der Klasse zur Verfügung:

public class AccountStatementPrinterTest {
  
  @TempDir
  private Path statementDir;    // <- zeigt bei Testausführung automatisch auf einen temporären, leeren Ordner
  
  @Test
  public void testPrintStatementToFile() {
    ...
  }
}

In manchen Situationen ist es sinnvoll, Testfälle vorübergehend zu deaktivieren – beispielsweise, wenn Systemkomponenten (noch) nicht verfügbar sind. Auskommentierte Tests geraten leicht in Vergessenheit. Mit @Disabled lassen sich daher einzelne Testmethoden oder ganze Klassen deaktivieren; sie werden bei der Ausführung übersprungen und im Testbericht als “ignored” bzw. “skipped” vermerkt.

Im vorherigen Beispiel wurden Kontoauszüge in einer lokalen Datei ausgegeben. Zukünftig sollen diese in einem Cloud-Speicher abgelegt werden. Da diese Funktionalität allerdings erst später implementiert wird, ist der entsprechende Testfall vorübergehend deaktiviert.

public class AccountStatementPrinterTest {
  
  @Disabled
  @Test
  public void testPrintStatementToCloud() {
    ...
  }
}

Testfälle sollten nicht grundlos deaktiviert werden. Insbesondere sollten Sie abwägen, ob es nicht sinnvoller ist, einen Test fehlschlagen zu lassen. Dokumentieren Sie den Grund in jedem Fall direkt in der Annotation:

public class AccountStatementPrinterTest {
  
  @Disabled("Cloud storage functionality not yet implemented.")
  @Test
  public void testPrintStatementToCloud() {
    ...
  }
}

Weitere Informationen finden Sie in der JUnit Dokumentation.

Test Suites

IDEs wie IntelliJ IDEA unterstützen das Ausführen einzelner Testmethoden, ganzer Klassen oder Ordner. Diese Ausführungskonfigurationen sind jedoch nur lokal und werden nicht automatisch im Team geteilt. Test Suites lösen dieses Problem, indem sie Tests zu logischen Gruppen bündeln – etwa nach Komponenten oder Features. Mit präzisen Selektoren lassen sich spezifische Klassen, Packages oder Methoden einschließen.

Da JUnit modular ist und Test Suites optional sind, wird eine zusätzliche Abhängigkeit benötigt. Fügen Sie diese in der build.gradle Datei im dependencies-Block hinzu. Die Version stammt aus der bereits eingebundenen JUnit BOM.

dependencies {
  testImplementation platform("org.junit:junit-bom:6.1.3")
  testImplementation "org.junit.jupiter:junit-jupiter"
  testImplementation "org.junit.platform:junit-platform-suite"  // <- neu
  testRuntimeOnly "org.junit.platform:junit-platform-launcher"
}

Eine Suite-Klasse kennzeichnen Sie mit @Suite. Optional kann mit @SuiteDisplayName ein sprechender Name festgelegt werden.

Im Beispiel möchten wir alle Tests rund um die Account-Funktionalität gruppieren, etwa AccountTest und AccountStatementPrinterTest:

@Suite
@SuiteDisplayName("Test all Account-related features")
public class AccountTestSuite {}

Ohne weitere Angaben findet JUnit allerdings keine zugehörigen Tests:

Suite [AccountTestSuite] did not discover any tests

Legen Sie daher fest, welche Tests zur Suite gehören.

Wichtige Selektoren sind dabei:

  • @SelectClasses ermöglicht das Auswählen beliebig vieler Testklassen
  • @SelectPackages ermöglicht das Auswählen aller Testklassen in einer Menge von Packages
  • @IncludePackages/@ExcludePackages ermöglicht die Inklusion und Exklusion von Subpackages innerhalb eines ausgewählten Packages
  • @IncludeClassNamePatterns/@ExcludeClassNamePatterns ermöglicht die Inklusion oder Exklusion einzelner Klassen innerhalb eines ausgewählten Packages mit Hilfe regulärer Ausdrücke für Klassennamen
  • @SelectMethod ermöglicht die Auswahl einzelner Testmethoden von bestimmten Testklassen

Für unser Beispiel nutzen wir die @SelectClasses Annotation:

@Suite
@SuiteDisplayName("Test all Account-related features")
@SelectClasses({ AccountTest.class, AccountStatementPrinterTest.class })  // <- welche Testklassen sind Teil der Suite  
public class AccountTestSuite {}

Weitere Informationen finden Sie in der JUnit Dokumentation.

Hinweis: Die in einer Suite ausgewählten Testklassen werden auch bei einem normalen Lauf des Gradle-Tasks test ausgeführt. Sie laufen dann einmal direkt und einmal über die Suite.

Bei komplexeren Szenarien kann ein zusätzliches Setup sinnvoll sein. Suite-Klassen können @BeforeSuite und @AfterSuite nutzen, analog zu @BeforeAll und @AfterAll bei Testklassen.

@BeforeSuite
static void beforeSuite() {
  // einmalige Ausführung vor der Testsuite 
}

@AfterSuite
static void afterSuite() {
  // einmalige Ausführung nach der Testsuite
}

Gutes Testen

Tests können nur die Anwesenheit von Fehlern zeigen, nicht deren Abwesenheit. Daher sind umfangreiche, kontinuierlich weiterentwickelte Test-Suites nötig. Vollständiges Testen, also das Prüfen aller möglichen Eingaben und Abläufe, ist bei nicht-trivialer Software unmöglich. Selbst eine hohe Testabdeckung (z.B. 100 % der Anweisungen) garantiert keine Fehlerfreiheit. Effektives Testen entsteht daher nicht durch “so viele Testfälle wie möglich”, sondern durch eine klug priorisierte Stichprobe.

Ein guter Testfall zeichnet sich durch vier Faktoren aus:

  1. repräsentativ – er steht stellvertretend für viele mögliche Eingaben
  2. fehlersensitiv – er deckt mit hoher Wahrscheinlichkeit einen Fehler im Testobjekt auf
  3. redundanzarm – er prüft nicht dasselbe wie ein anderer Testfall
  4. ökonomisch – er deckt maximal viele Fehler auf

Das Ziel ist es, mit wenigen Testfällen eine möglichst große Wirkung zu erzielen.

Äquivalenzklassen

Ein bewährtes Verfahren zur Reduktion von Testfällen ist die Äquivalenzklassenbildung. Mögliche Eingabewerte werden in Klassen eingeteilt, unter der Annahme, dass sich das Testobjekt für alle Werte einer Klasse gleichartig verhält. Alle Klassen zusammen müssen den gesamten Eingabebereich abdecken, einschließlich ungültiger Werte. Zusätzlich können Lasttestklassen definiert werden (z.B. leere Felder, Null-Referenzen, Sonderzeichen). Beim Testen wird aus jeder Klasse mindestens ein repräsentativer Wert ausgewählt.

Die naive Bankimplementierung nutzt eine stark vereinfachte Implementierung zur Zinsberechnung. Die Methode calculateInterest(double balance, double interestPerYear, boolean isInterestCapped, double interestCap) berechnet Zinsen für einen Kontostand. Bei negativem Kontostand werden keine Zinsen gutgeschrieben (Rückgabewert 0). Der Zinssatz muss zwischen 0 und 1 liegen. Das Zinslimit darf nicht negativ sein. Ungültige Parameter führen zu einer IllegalArgumentException, unabhängig vom Kontostand.

/**
 * Berechnet Zinsen für einen gegebenen Kontostand. 
 * Optional kann ein Zinslimit gesetzt werden, sodass bei der 
 * Berechnung der Zinsen ein Maximalbetrag berücksichtigt wird. 
 *
 * Beispiel: 
 * 2% p. a. Zinsen bis 500 Euro Kontostand 
 *
 * @param balance Kontostand  
 * @param interestPerYear Zinssatz pro Jahr (0.0-1.0)  
 * @param isInterestCapped Boolean ob ein Zinslimit existiert  
 * @param interestCap Maximal zu verzinsender Betrag  
 * @return Zinswert
 */
protected double calculateInterest(double balance, double interestPerYear,
    boolean isInterestCapped, double interestCap) {
  if (interestPerYear < 0 || interestPerYear > 1) {
    throw new IllegalArgumentException("Zinssatz muss zwischen 0.00 und 1.00 liegen");
  }
  if (interestCap < 0) {
    throw new IllegalArgumentException("Zinslimit darf nicht negativ sein");
  }
  if (balance < 0) {
    return 0;
  }
  double balanceBase = balance;
  if (isInterestCapped) {
    balanceBase = Math.min(balanceBase, interestCap);
  }
  return balanceBase * interestPerYear;
}

Die Eingabewerte lassen sich über einen Klassifikationsbaum strukturieren. Auf Ebene 2 stehen die Parameter, auf Ebene 3 ihre Äquivalenzklassen, auf Ebene 4 mögliche Repräsentanten. Ungültige Klassen (und Repräsentanten) werden Rot dargestellt. minDouble und maxDouble stehen dabei für den kleinsten bzw. größten darstellbaren Wert, in Java also -Double.MAX_VALUE und Double.MAX_VALUE. Wir gehen vereinfachend davon aus, dass alle Werte höchstens zwei Nachkommastellen haben: Beträge (Kontostand und Zinslimit) sind cent-genau, der Zinssatz wird in ganzen Prozent betrachtet. Dadurch sind die Klassen lückenlos, z.B. folgt auf -0.01 direkt 0.

Problem: Trotz Äquivalenzklassen bleiben (zu) viele mögliche Eingabekombinationen übrig (im Beispiel: 6*9*2*6).

Es gibt daher einige Heuristiken für die Reduktion der Eingabewertekombinationen:

  • Aus jeder Äquivalenzklasse wird mindestens ein Wert ausgewählt ( mindestens ein Testfall pro Klasse)
  • Abhängige Eingabeparameter werden in allen Kombinationen ihrer jeweiligen “normalen” Äquivalenzklassen getestet. Parameter sind abhängig, wenn sie gemeinsam das Verhalten des Programms steuern (und deshalb nicht unabhängig voneinander betrachtet werden können)
  • Ein Wert aus einer Fehleräquivalenzklasse wird in genau einem Testfall verwendet (Fehleräquivalenzklassen sind solche Äquivalenzklassen, die unzulässige Eingabewerte zusammenfassen)
  • Ein Wert aus einer Lasttestklasse wird ebenfalls genau einmal in einem Testfall verwendet (Lasttestklassen sind solche Äquivalenzklassen, die besonders “große/lange/…” Eingabewerte zusammenfassen)
  • Bei mehreren Parametern wird höchstens einer mit einem Wert aus einer Fehler- oder Lasttestklasse belegt (Verdeckung von Fehlern vermeiden)

Grenzwertanalyse

Bei Äquivalenzklassen, die als Intervalle dargestellt sind, treten Fehler häufig an den Grenzen auf – zum Beispiel durch falsch implementierte Schleifenbedingungen. Die Grenzwertanalyse betrachtet gezielt Werte in unmittelbarer Nähe der Grenzen: solche, die “gerade noch” das gleiche Ergebnis liefern, bzw. “gerade schon” ein anderes.

Beispiel: Äquivalenzklassen für einen Parameter, für dessen zulässige Eingabewerte reelle Zahlen mit zwei Nachkommastellen zwischen -1 und +1 sind. Vor der Grenzwertanalyse ergeben sich die folgenden Äquivalenzklassen:

Bei der Grenzwertanalyse werden nun jeweils die Grenzwerte zwischen den einzelnen Äquivalenzklassen abgespalten, sodass vier weitere Äquivalenzklassen entstehen.

Zuvor wurde grundsätzlich angenommen, dass sich ein Testobjekt für alle Eingaben einer Äquivalenzklasse gleich verhält, sodass beliebige Werte einer Äquivalenzklasse zum Testen verwendet werden können. Durch das Separieren der Grenzwerte in eigene Äquivalenzklassen gilt dies weiterhin, während die Grenzwerte hervorgehoben werden.

Parametrisiertes Testen

Möchte man ein Testobjekt mit verschiedenen Eingaben prüfen, entstehen viele ähnliche Methoden mit dupliziertem Code.

JUnit bietet mit parametrisierten Tests einen eleganten Ansatz: Die Testmethode selbst erhält Parameter und wird mit @ParameterizedTest annotiert. Zusätzlich ist eine Source-Annotation nötig, die JUnit mit Eingabewerten versorgt.

Als Beispiel testen wir die statische Methode validAppointment der Klasse AppointmentUtils. Sie erwartet einen Wochentag (DayOfWeek), eine Stunde ( int) und eine Minute (int). Sie gibt true zurück, wenn der Termin zulässig ist – also an einem Arbeitstag (Montag bis Freitag) ab 8:00 Uhr und vor 17:00 Uhr liegt. Die Zeit 8:00 ist somit zulässig, 17:00 nicht mehr.

Eine einfache Variante variiert nur die Stunde, die anderen Argumente sind konstant. Die Methode hat einen einzigen Parameter, hour. Als Source nutzen wir @ValueSource, die primitive Werte und Strings für Einzelparameter liefert. JUnit ruft die Testmethode für jeden Wert einmal auf:

@ParameterizedTest
@ValueSource(ints = { 8, 10, 15, 16 })        // <- Eingabewerte für 'hour'
void validAppointmentOnWorkdayHours(int hour) {  // <- Parameter 'hour'
  assertTrue(AppointmentUtils.validAppointment(DayOfWeek.MONDAY, hour, 15));
}

Weitere Informationen zur @ValueSource Annotation finden Sie in der JUnit Dokumentation.

Möchten Sie unterschiedliche Wochentage testen, bietet sich @EnumSource an. Sie funktioniert für Testmethoden mit einem Enum-Parameter. Ohne weitere Angaben wird die Testmethode für jeden Wert des Enums aufgerufen. Mit names lassen sich einzelne Werte auswählen, und mit mode = EXCLUDE werden die genannten Werte stattdessen ausgeschlossen.

Im folgenden Beispiel prüfen wir 13:00 Uhr an jedem Wochentag. An Arbeitstagen erwarten wir true, am Wochenende false. Statt in der Testmethode zwischen beiden Fällen zu unterscheiden (und damit die Logik des Testobjekts im Test zu duplizieren), schreiben wir zwei Tests. JUnit ruft den ersten Test fünfmal (Montag bis Freitag) und den zweiten Test zweimal (Samstag und Sonntag) auf.

@ParameterizedTest
@EnumSource(value = DayOfWeek.class,          // <- Eingabewerte aus Enum 'DayOfWeek'
    names = { "SATURDAY", "SUNDAY" },         // <- ...mit diesen Namen
    mode = EXCLUDE)                           // <- ...aber ausgeschlossen
void validAppointmentOnWorkdays(DayOfWeek day) {
  assertTrue(AppointmentUtils.validAppointment(day, 13, 0));
}

@ParameterizedTest
@EnumSource(value = DayOfWeek.class, names = { "SATURDAY", "SUNDAY" })
void invalidAppointmentOnWeekend(DayOfWeek day) {
  assertFalse(AppointmentUtils.validAppointment(day, 13, 0));
}

Oft hängt das Verhalten eines Testobjekts von Kombinationen mehrerer Parameter ab. Wie in vorherigen Abschnitten besprochen, müssen in solchen Fällen alle Kombinationen getestet werden.

JUnit bietet daher auch Sources für Testmethoden mit mehreren Parametern, beispielsweise @CsvSource. Sie nimmt eine Liste von Strings entgegen, die jeweils eine kommagetrennte Liste von primitiven Werten (oder Strings) darstellen.

Im Beispiel prüfen wir verschiedene Uhrzeiten am Sonntag. Die Testmethode hat zwei Parameter, hour und minute. JUnit ruft sie pro CSV-String einmal auf, trennt den String an den Kommas und versucht die Elemente in die benötigten Typen umzuwandeln. Beim ersten Aufruf wird somit beispielsweise hour auf 9 und minute auf 0 gesetzt.

@ParameterizedTest
@CsvSource({ "9,00", "10,25", "11,45", "16,45" })      // <- Eingabewerte als Csv
void invalidAppointmentOnSunday(int hour, int minute) {  // <- zwei Parameter
  assertFalse(
      AppointmentUtils.validAppointment(DayOfWeek.SUNDAY, hour, minute));
}

Auch das erwartete Ergebnis kann als weiterer Wert in der CSV-Zeile stehen. Das eignet sich gut für die Grenzwerte aus dem vorherigen Abschnitt: Für die Uhrzeiten rund um 8:00 und 17:00 Uhr prüfen wir, ob der Termin an einem Montag zulässig ist.

@ParameterizedTest
@CsvSource({ "7,59,false", "8,00,true", "8,01,true", "16,59,true", "17,00,false" })
void appointmentBoundaries(int hour, int minute, boolean expected) {
  assertEquals(expected,
      AppointmentUtils.validAppointment(DayOfWeek.MONDAY, hour, minute));
}

Alternativ können Sie mit @CsvFileSource auch Eingabewerte aus einer externen CSV-Datei laden.

Für komplexe Datentypen eignet sich @MethodSource. Sie referenziert eine statische Methode, die einen Stream<Arguments> zurückgibt. Die Typen der Werte in einem Arguments Objekt müssen dabei den Typen der Testmethoden-Parameter entsprechen.

public static Stream<Arguments> parameterProvider() {   // <- Statische Methode zur Generierung von Argumenten
  return Stream.of(Arguments.of(DayOfWeek.MONDAY, 12),  // <- Eingabewerte für einen Testdurchlauf
      Arguments.of(DayOfWeek.TUESDAY, 13),
      Arguments.of(DayOfWeek.WEDNESDAY, 14));
}

@ParameterizedTest
@MethodSource("parameterProvider")                      // <- Namen der statischen Methode zur Generierung von Argumenten
void validAppointmentWithMethodSource(DayOfWeek day, int hour) { // <- zwei Parameter 
  assertTrue(AppointmentUtils.validAppointment(day, hour, 15));
}

Weitere Informationen zum parametrisierten Testen finden Sie in der JUnit Dokumentation.

Beispiel: Zinsberechnung testen

Mit parametrisierten Tests lassen sich die Äquivalenzklassen und Grenzwerte der Methode calculateInterest aus den vorherigen Abschnitten kompakt umsetzen. Die Methode ist protected, die Testklasse BankTest liegt daher im selben Package wie Bank.

Die gültigen Eingaben testen wir mit @CsvSource. Der Parameter isInterestCapped (Existiert ein Limit?) und der Kontostand beeinflussen gemeinsam das Ergebnis und werden daher kombiniert. Zusätzlich enthalten die Testfälle die Grenzwerte des Zinssatzes (0.00 und 1.00). Da der Rückgabewert ein double ist, vergleichen wir mit einer erlaubten Abweichung:

private static final double DELTA = 0.0001;

private final Bank bank = new Bank();

@ParameterizedTest
@CsvSource({
    // balance, interestPerYear, isInterestCapped, interestCap, expected
    "-100, 0.50, false,   0,  0",     // negativer Kontostand: keine Zinsen
    "   0, 0.50, false,   0,  0",
    "1000, 0.02, false,   0, 20",     // kein Limit
    "1000, 0.02, true,  500, 10",     // Kontostand über dem Limit
    " 300, 0.02, true,  500,  6",     // Kontostand unter dem Limit
    "1000, 0.00, false,   0,  0",     // Grenzwert Zinssatz
    "1000, 1.00, false,   0, 1000"    // Grenzwert Zinssatz
})
void calculateInterest_ValidInput(double balance, double interestPerYear,
    boolean isInterestCapped, double interestCap, double expected) {
  assertEquals(expected,
      bank.calculateInterest(balance, interestPerYear, isInterestCapped, interestCap),
      DELTA);
}

Gemäß unserer Heuristik testen wir die Werte der Fehleräquivalenzklassen jeweils einzeln: In jedem Testfall ist höchstens ein Parameter ungültig. Auch hier lassen sich die Werte mit @ValueSource übergeben:

@ParameterizedTest
@ValueSource(doubles = { -1.0, -0.01, 1.01, 5.0 })
void calculateInterest_InvalidInterestRate(double interestPerYear) {
  assertThrows(IllegalArgumentException.class,
      () -> bank.calculateInterest(100, interestPerYear, false, 0));
}

@ParameterizedTest
@ValueSource(doubles = { -10, -0.01 })
void calculateInterest_InvalidInterestCap(double interestCap) {
  assertThrows(IllegalArgumentException.class,
      () -> bank.calculateInterest(100, 0.02, true, interestCap));
}

Ein negativer Kontostand ist zwar eine gültige Äquivalenzklasse (mit Ergebnis 0), darf einen ungültigen Zinssatz aber nicht verdecken. Auch das lässt sich testen:

@ParameterizedTest
@ValueSource(doubles = { -1.0, 1.01 })
void calculateInterest_InvalidInterestRateIsNotMaskedByNegativeBalance(double interestPerYear) {
  assertThrows(IllegalArgumentException.class,
      () -> bank.calculateInterest(-100, interestPerYear, false, 0));
}