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.javaAnschließ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.AccountTestWichtig: 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 testWir 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
teststarten: Im Gradle Tool Window unter “Verification” den Tasktestausfü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. ist0.1 + 0.2nicht genau0.3).assertEquals(expected, actual, delta)akzeptiert alle Werte, die höchstens umdeltaabweichen:
@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:
-
@SelectClassesermöglicht das Auswählen beliebig vieler Testklassen -
@SelectPackagesermöglicht das Auswählen aller Testklassen in einer Menge von Packages -
@IncludePackages/@ExcludePackagesermöglicht die Inklusion und Exklusion von Subpackages innerhalb eines ausgewählten Packages -
@IncludeClassNamePatterns/@ExcludeClassNamePatternsermöglicht die Inklusion oder Exklusion einzelner Klassen innerhalb eines ausgewählten Packages mit Hilfe regulärer Ausdrücke für Klassennamen -
@SelectMethodermö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:
- repräsentativ – er steht stellvertretend für viele mögliche Eingaben
- fehlersensitiv – er deckt mit hoher Wahrscheinlichkeit einen Fehler im Testobjekt auf
- redundanzarm – er prüft nicht dasselbe wie ein anderer Testfall
- ö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));
}