Einleitung
Ein einziger Fehler in einer Software kann katastrophale Folgen haben. Doch warum wird das Testen dann oft venachlässig?
Das Testen soll die Qualität der Software sicherstellen. Ein gut getestetes Programm enthält weniger Fehler, erfüllt seine Anforderungen besser, läuft stabiler und effizienter als ein nicht getestetes Programm.
Dennoch ist das Testen selbst ein oftmals mühevoller und teurer Vorgang, denn während des Testens entwickeln die Programmierer keinen neuen Code. Im ersten Moment scheinen sie also nicht produktiv zu sein.
Weil das Testen ein teurer und aufwändiger Vorgang ist, haben sich im Laufe der Zeit Frameworks entwickelt, die den Testvorgang automatisieren möchten. Sie erlauben es Entwicklern, mit wenig Aufwand kleine Programme zu schreiben, die den Produktivcode automatisch testen.
Ein etablierter Standard auf diesem Gebiet ist das Framework JUnit, das aktuell in der Version 5 vorliegt. JUnit arbeitet auf der Basis vieler moderner Java-Features wie z.B. Annotationen und Lambda-Ausdrücken und hat sich in der Softwareentwicklung als de-facto-Standard etabliert. Mit JUnit ist es möglich, kleine Test-Methoden zu schreiben, die Methoden eines Programms mit Testdaten aufrufen und die Ergebnisse prüfen. Erhalten die Ergebnisse die vordefinierten Werte, gilt ein Test als erfolgreich.
So werden Tests wiederholbar, d.h. sie können immer und immer wieder aufgerufen werden. Wurde am Code eine Änderung vorgenommen, können alle Testfälle noch einmal gestartet werden, um die Korrektheit der Tests zu gewährleisten. Die Tests müssen zwar noch programmiert werden, aber durch die Automatisierung und Wiederholbarkeit ist der Aufwand erheblich geringer als bei manuellem Testen.
Obwohl JUnit als Basis für viele Testumgebungen und -strategien fungiert, lohnt es sich, einen Blick über JUnit hinaus zu werfen, denn in gewissen Anwendungsfällen ist JUnit als sehr allgemeines Test-Framework wiederum eingeschränkt. Und deswegen haben sich auf der Basis JUnit im Laufe der Zeit weitere spezifischere Test-Frameworks für bestimmte Szenarien entwickelt.
Dieser Artikel gibt einen Überblick über einige ergänzende Test-Frameworks und erläutert, wie sie helfen, die Testqualität bei komplexen Szenarien zu steigern.
JUnit und seine Grenzen
JUnit als de-facto-Standard überzeugt vor allem durch seine einfache Syntax. Eine Testmethode wird mit der @Test-Annotation markiert und somit dem Framework übergeben. Gestartet wird die Testmethode von JUnit selbst. Die Testmethoden rufen den zu testenden Code auf, übergeben die Testdaten und werten die Ergebnisse aus. Zum Auswerten der Ergebnisse genügt die einfache assertEquals(expected, actual)-Methode. Das folgende Code-Beispiel zeigt eine Test-Methode, die eine einfache addieren-Methode testet:
@Test
void testAdd() {
int sum = MathUtils.add(4,6);
assertEquals(10, sum);
}
Die grundlegende Syntax von JUnit ist einfach und schnell zu lernen. Seit Version 5 bietet es zusätzlich leistungsstarke Features wie geschachtelte Testfälle und eine verbesserte Parametrisierung.
Trotz seiner Einfachheit hat JUnit seine Grenzen. Einfache Assertions reichen für komplexe Szenarien oftmals nicht aus, z.B. wenn komplexe Objektvergleiche oder Vergleiche von Listen durchgeführt werden sollen. Testmethoden sollten keine Logik enthalten, denn Logik muss wieder selbst getestet werden. Die oben genannten Vergleichen jedoch kommen ohne if-Anweisungen und Schleifen nicht aus. Für diese Tests bietet JUnit keine Lösungen.
In komplexen Architekturen kann reines JUnit auch nur begrenzt eingesetzt werden, nämlich dann wenn Beziehungen zwischen Objekten getestet werden sollen. Angenommen, Klasse A greift auf Klasse B zu. Wenn ein Test für A fehlschlägt, kann die Ursache auch in B liegen. Je komplexer die Abhängigkeiten, desto schwieriger wird die Fehlersuche.
Ein weiterer wichtiger Punkt sind Architekturtests. JUnit dient allein dazu, zu testende Methoden aufzurufen und deren Ergebnisse auszuwerten. Aber gerade in größeren Projekten und Teams mit Architektur- und Coderichtlinien kann es sinnvoll sein, genau deren Einhaltung zu prüfen, also bspw. die Einhaltung von Namenskonventionen, den Aufbau von Modulgrenzen oder die Gesamt-Architektur zu überprüfen. Auch dies kann JUnit nicht leisten.
Auch bei Integrationstests hat JUnit einige Schwächen: Datenbankanwendungen z.B. erfordern, dass jeder Test auf einem klar definierten Datenbankzustand ausgeführt wird, damit die Tests nicht von einem bestimmten Datenbankzustand und somit von vorher korrekt durchgelaufenen Tests abhängen. JUnit selbst bietet keinerlei Unterstützung für Datenbankanwendungen an.
Die in diesem Artikel vorgestellten Frameworks basieren auf JUnit und erweitern es gezielt in verschiedenen Bereichen:
- AssertJ: Verbesserung der Lesbarkeit und Ausdrucksstärke
- ArchUnit: Realisierung von Architektur- und Regeltests
- Mockito: Mocking von Klassen und Modulen zur Verringerung der Abhängigkeiten während Tests
AssertJ
Assertions sind essenziell, um sicherzustellen, dass der Code das erwartete Verhalten zeigt. Dabei wird ein Verhalten definiert und mit dem Ergebnis verglichen. Die Methode in Listing 1 demonstriert genau eine solche Überprüfung: assertEquals() prüft, ob die erwartete Summe der tatsächlich von der zu testenden Methoden berechneten Summe entspricht. Für einfache Tests sind die in JUnit enthaltenen Standard-Assertions ausreichend. Allerdings stoßen sie bei komplexeren Szenarien an ihre Grenzen, insbesondere in Bezug auf Lesbarkeit und Fehlermeldungen.
AssertJ erweitert JUnit um zusätzliche Assertions und aussagekräftigere Fehlermeldungen. Dabei bleibt der grundsätzliche Aufbau einer Testmethode gleich, einschließlich der @Test-Annotation, wird jedoch um die leistungsfähigen Assertions von AssertJ ergänzt.
Ein wesentlicher Vorteil von AssertJ ist die Einführung einer gut lesbaren Fluent-API. Diese sorgt für längere, aber intuitivere und besser verständliche Assertions.
In JUnit würde man den Namen einer Person folgendermaßen prüfen:
assertEquals(“Alice”, person.getName());
Ein Problem hierbei ist die Reihenfolge der Parameter. Die JUnit-Assertions erwarten als ersten Parameter den erwarteten Wert und als zweiten den tatsächlichen Wert – dies birgt Verwechslungsgefahr. Mit AssertJ wird dieselbe Assertion so formuliert:
assertThat(person.getName()).isEqualTo(“Tom Ate”);
Diese Formulierung ist intuitiv lesbar und lässt sich in einen natürlichsprachlichen Satz übersetzen: „Nimm an, dass der Name der Person gleich ‚Tom Ate‘ ist.“
AssertJ zeigt seine wahre Stärke bei komplexeren Prüfungen:
assertThat(person)
.isNotNull()
.hasFieldOrPropertyWithValue("age", 30)
.extracting(Person::getJob)
.isEqualTo("Engineer");
Dieser Code lässt sich ebenfalls leicht in natürlicher Sprache ausdrücken: „Nimm an, dass eine Person vorhanden ist, dass sie ein Feld ‚age‘ mit dem Wert 30 besitzt, extrahiere den Beruf und nimm an, dass dieser ‚Engineer‘ ist.“
Mit AssertJ lassen sich Collections einfach und ohne komplexe Kontrollstrukturen prüfen:
assertThat(list).hasSize(3).contains(“Apple”, “Banana”).doesNotContain(“Orange”);
Auch diese Anweisung kann man auf den ersten Blick verstehen: „Nimm an, dass die Liste die Größe 3 hat, dass sie ‚Apple‘ und ‚Banana‘ enthält und ‚Orange‘ nicht enthält.“ Eine Überprüfung einer Collection kommt also nicht nur ohne logische Sprachelemente und die damit verbundene Fehleranfälligkeit aus, sondern bringt auch eine hohe Lesbarkeit und Verständlichkeit mit sich.
Auch die Überprüfung, ob bestimmte Exceptions mit bestimmten Fehlermeldungen geworfen werden, ist schnell und einfach ausgeführt.
assertThatThrownBy(() -> methodThatThrows())
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Invalid argument");
AssertJ verbessert nicht nur die Lesbarkeit von Tests, sondern auch die Verständlichkeit von Fehlermeldungen. An einem einfachen Beispiel können wir in der Gegenüberstellung die besseren Fehlermeldungen sofort erkennen:
- JUnit: “expected <42> but was <41>”
- AssertJ: “Expected value to be <42> but was <41>. Difference: -1”
Bei komplexeren Test-Szenarien werden die Fehlermeldungen noch aussagekräftiger.
AssertJ bringt also die folgenden Verbesserungen gegenüber JUnit mit:
- Verbesserte Lesbarkeit und Wartbarkeit von Tests
- Präzisere Fehlermeldungen
- Breite Unterstützung für verschiedene Java-Objekte (z. B. Collections)
Dank dieser Verbesserungen stellt AssertJ eine ideale Ergänzung oder sogar einen vollständigen Ersatz für die Standard-Assertions von JUnit dar.
Architekturanforderungen testen mit ArchUnit
Eine konsistente Architektur ist das Fundament einer erweiterbaren und änderbaren Anwendung. Doch gerade in größeren Teams ist es nicht einfach, die Einhaltung der Architekturvorgaben zu erzwingen. So könnte innerhalb einer Schichtenarchitektur eine Schicht übersprungen werden, falsche Interfaces können verwendet werden oder die Namenskonvention für CRUD-Methoden könnte gebrochen werden. JUnit allein bietet keine Mechanismen zur Überprüfung solcher Architekturaspekte. Architekturdrifts können sich negativ auf Wartbarkeit, Erweiterbarkeit und Team-Konsistenz auswirken.
ArchUnit analysiert den Bytecode einer Java-Anwendung und erkennt Verstöße gegen definierte Architekturregeln.. Es definiert Regeln für Paketstrukturen, Abhängigkeiten, Namenskonventionen usw. und ermöglicht somit automatisierte Architekturtests mit JUnit. Auch hier bildet JUnit wieder die Grundlage.
Mögliche Anwendungsfälle für ArchUnit ist z.B. das Erkennen unerwünschter Abhängigkeiten (z.B. eine Serviceklasse darf nicht direkt auf ein Repository zugreifen), die Einhaltung von Namenskonventionen (z.B. Namen von Klassen im Paket .controller müssen mit „Controller“ enden), Erkennen von zyklischen Abhängigkeiten, korrekte Verwendung von Annotationen sicherstellen, Layer-Konformität prüfen.
In ArchUnit werden Architekturregeln mit einer sehr gut lesbaren Fluent-API definiert. Wie bereits bei AssertJ können die Regeln bei ArchUnit fast wie natürlichsprachige Sätze gelesen werden.
ArchRule rule = noClasses()
.that().resideInAPackage("..service..")
.should().dependOnClassesThat().resideInAPackage("..controller..");
Diese Regel lautet in deutscher Sprache ungefähr wie folgt: Keine Klasse, die im Paket ‚service‘ liegt, darf abhängig sein von Klassen, die sich im Paket ‚controller‘ befinden.“ Mit dieser Regel kann die Einhaltung der korrekten Abhängigkeiten in einer Schichten-Architektur geprüft werden.
Die folgende Regel prüft, dass die Namen aller Klassen, die im Paket „service“ liegen, mit „Service“ enden.
noClasses().that().resideInAPackage(“..service..”)
.should().haveSimpleNameEndingWith(“Service”);
Die Aufgabe von ArchUnit ist also nicht das klassische Testen von Methoden mittels Ein- und Ausgabewerten, sondern es ermöglicht durch geschickt formulierte Regeln die frühzeitige Erkennung von Architekturverletzungen, und damit wird die Wartbarkeit und Konsistenz des Codes erhöht.
Mocking und Integrationstests mit Mockito
In größeren Softwaresystemen bestehen häufig Abhängigkeiten zwischen verschiedenen Komponenten. Wenn eine Methode getestet wird, die andere Objekte aufruft, kann das zu Problemen führen: Fehler in den abhängigen Komponenten können dazu führen, dass der Test fehlschlägt, selbst wenn die getestete Methode korrekt funktioniert.
Das Problem soll einmal mit der folgenden kleinen Architektur illustriert werden: Wir haben eine Klasse StudentService und die Methode addStudent () soll getestet werden, aber sie wiederum ruft die Methode exists () der Klasse StudentDao auf. Wir möchten StudentService unabhängig von StudentDao testen. Würde exists() fehlerhaft sein, könnte das unseren Test beeinflussen, obwohl wir nur addStudent() testen wollen.
Genau hier kommt Mockito ins Spiel. Mockito ermöglicht es, Abhängigkeiten durch sogenannte Mocks zu ersetzen und so Unit-Tests unabhängig von anderen Komponenten auszuführen. Mocks kann man sich vereinfacht als „virtuelle Implementierungen“ vorstellen. Der Test wird also nicht mit einer Abhängigkeit zu einem realen Objekt ausgeführt, sondern zu einem gemockten Objekt, das immer korrektes Verhalten zeigt. Wenn der Test dann fehlschlägt, kann es nicht mehr an der Abhängigkeit liegen, sondern der Fehler ist tatsächlich in der getesteten Methode. Wenn man mit Hilfe von Mockito alle Abhängigkeiten für den Test wegmockt, kann man die Methoden wirklich isoliert testen.
Das folgende Beispiel zeigt die beschriebene Abhängigkeit. Die Abhängigkeit selbst wird im Konstruktor gesetzt. Die Methode addStudent() ruft zunächst exists() auf, um zu prüfen, ob der Student bereits gespeichert ist. Erst wenn das nicht der Fall ist, wird saveStudent() aufgerufen. Ein Fehler in exists() könnte daher den Test beeinflussen.
public class StudentService {
private final StudentDao studentDao;
public StudentService(StudentDao studentDao) {
this.studentDao = studentDao;
}
public boolean addStudent(Student student) {
if (studentDao.exists(student.getMatriculationNr())) {
return false;
}
return studentDao.save(student);
}
}
Ohne Mocking könnte ein Fehler in StudentDao.exists() dazu führen, dass der Test von addStudent() fehlschlägt, obwohl addStudent() korrekt funktioniert. Der Test sieht dann folgendermaßen aus:
public class StudentServiceTest {
@Mock
private StudentDao daoMock;
private StudentService service;
@Before
public void setUp() {
MockitoAnnotations.initMocks(this);
service = new StudentService(daoMock);
}
@Test
public void testAddStudent_WhenStudentExists_ShouldReturnFalse() {
Student student = new Student("12345");
when(daoMock.exists(student.getMatriculationNr())).thenReturn(true);
boolean result = service.addStudent(student);
assertFalse(result);
verify(daoMock).exists(student.getMatriculationNr());
verifyNoMoreInteractions(daoMock);
}
@Test
public void testAddStudent_WhenStudentDoesNotExist_ShouldSaveStudent() {
Student student = new Student("12345");
when(daoMock.exists(student.getMatriculationNr())).thenReturn(false);
when(daoMock.save(student)).thenReturn(true);
boolean result = service.addStudent(student);
assertTrue(result);
verify(daoMock).exists(student.getMatriculationNr());
verify(daoMock).save(student);
}
}
Im Test lassen wir uns ein gemocktes Objekt von Mockito geben, also die oben erwähnte virtuelle Implementierung, die immer korrekt arbeitet, und diese geben wir in den Konstruktor des zu testenden Objekts. MockitoAnnotations.initMocks(this) initialisiert alle mit @Mock-annotierten Felder. Somit ist die Abhängigkeit geklärt. Jetzt stellt sich nur noch die Frage: Wie kann man sicherstellen, dass sich das gemockte Objekte auch so verhält wie gewünscht?
Zum Konfigurieren von Mockito wird das when-then-Pattern verwendet. Im Beispiel wurde diese Anweisung formuliert: when(daoMock.exists(matriculationNumber)).thenReturn(true);
Auch diese Anweisung lässt sich wieder lesen wie ein englischer Satz. Das ist die einfachste Konfigurationsart in Mockito. Darüber hinaus bietet Mockito noch zahlreiche weitere und sehr mächtige Möglichkeiten, ein Objekt so zu konfigurieren, dass es für einen bestimmten Test das erwartete Verhalten zeigt.
Die Methode verify() ermöglicht zu prüfen, ob eine Methode mit bestimmten Parametern aufgerufen wurde und verifyNoMoreInteractions() stellt sicher, dass keine unerwarteten Methodenaufrufe erfolgen.
Durch den Einsatz von Mockito können wir StudentService isoliert testen, ohne dass sich Fehler in StudentDao auf unsere Tests auswirken. Dies ermöglicht präzisere Unit-Tests und erleichtert das Debugging.
Fazit
Das Java-Ökosystem hat eine ganze Reihe an verschiedenen Test-Frameworks hervorgebracht. Viele weitere existieren für spezielle Anwendungsfälle, und sie alle vorzustellen, würde den Rahmen dieses Artikels bei weitem sprengen. Auch die hier vorgestellten Test-Frameworks bieten noch erheblich mehr Möglichkeiten, die über die gezeigten Beispiele hinausgehen.
Tests sind ein essenzieller Bestandteil der Softwareentwicklung und helfen nicht nur, Fehler frühzeitig zu erkennen, sondern auch die Codequalität und Wartbarkeit langfristig zu verbessern. Durch die Kombination verschiedener Teststrategien – von Unit-Tests mit JUnit über Architekturtests mit ArchUnit bis hin zu Mocking mit Mockito – lassen sich robuste und gut strukturierte Anwendungen entwickeln. Wer sich intensiver mit Testautomatisierung beschäftigt, wird feststellen, dass ein durchdachtes Testkonzept maßgeblich zur Effizienz und Stabilität eines Projekts beiträgt.