Clean Code in JUnit 5 und Spring Boot Tests
Clean Code ist auch im Unit-Test Pflicht. Hier schreibe ich eine saubere JUnit 5 Testklasse und übersetze sie anschließend in einen Spring Boot Test. Außerdem zeige ich ein paar JUnit 5 Features zum Parametrisieren von Tests und zum Testen von Exceptions.
(JUnit 5 Logo 😮) |
Clean Code im JUnit Test
Das Schreiben von möglichst einfachem und gut wartbaren Code (Clean Code) ist für viele Entwickler selbstverständlich. Die Unit-Tests zu diesem Code haben leider häufig eine schlechtere Code-Qualität. Dabei sollte es eigentlich bekannt sein, dass auch Unit-Tests leicht verständlichen und gut wartbaren Code benötigen, insbesondere wenn sie regelmäßig und automatisiert ausgeführt werden. In einem der bekanntesten Bücher zum Thema Clean Code von Robert Martin, gibt es deshalb ein eigenes Kapitel zu JUnit.
Ich zeige euch nun einen JUnit Test und erkläre dann, welche Clean Code Prinzipien ich dabei angewendet habe. Getestet wird der InMemoryItemStore, den ich in einem früheren Blog-Post kernkonzepte-von-spring-beans-und.html im Rahmen einer Demo erstellt hatte. Der InMemoryItemStore hat 3 public Methoden:
- saveItem - generiert eine zufällige Id für das übergebene Item und fügt es einer Map hinzu.
Zum Generieren der Id nutzt der InMemoryItemStore eine weitere Klasse IdGenerator. - findItem - sucht ein Item in der Map anhand der übergebenen Id.
- deleteItem - löscht ein Item aus der Map anhand der übergebenen Id.
JUnit 5 Test für InMemoryItemStore
import static org.junit.jupiter.api.Assertions.*;
...
class InMemoryItemStoreJunitTest {
InMemoryItemStore itemStore;
String existingItemId;
@BeforeEach
void setup() {
itemStore = new InMemoryItemStore();
itemStore.initStore();
itemStore.idGenerator = new IdGenerator();
Item testData = new Item();
testData.setName("Book");
existingItemId = itemStore.saveItem(testData);
assertThat(existingItemId).isNotBlank();
}
@Test
void findItemPositiveTest() {
Item item = itemStore.findItem(existingItemId);
assertEquals("Book", item.getName());
}
@Test
void findItemNegativeTest() {
assertNull(itemStore.findItem("unknown"));
}
@Test
void deleteItemPositiveTest() {
itemStore.deleteItem(existingItemId);
assertNull(itemStore.findItem(existingItemId));
}
@Test
void deleteItemExceptionTest() {
try {
itemStore.deleteItem("123");
} catch(NoSuchElementException ex) {
return;
}
fail("NoSuchElementException expected");
}
}
- Unabhängigkeit: Alle 4 Tests sind unabhängig von einander, so dass sie in beliebiger Reihenfolge ausgeführt werden können.
Gäbe es zum Beispiel einen Test der Methode saveItem und einen abhängigen Test der Methode deleteItem, der das Item löscht, welches zuvor in saveItem erstellt wurde, so wäre das ein Verstoß gegen das Prinzip der Unabhängigkeit. Der Entwickler kann dann im Fehlerfall nicht direkt erkennen, ob der Fehler in der Methode saveItem oder deleteItem versteckt ist. - JUnit 5 zwingt uns zur Unabhängigkeit der Tests, indem für jeden Test (@Test annotierte Methode) eine neue Instanz der Klasse InMemoryItemStoreJunitTest erstellt wird. Dadurch muss die hier verwendete Variable existingItemId vor jeden Test neu definiert werden. Das mache ich in der @BeforeEach annotierten setup Methode, welche den zu testenden InMemoryItemStore instanziiert und Testdaten für jeden Test vorbereitet.
@BeforeEach annotierte Methoden werden vor jedem Test ausgeführt, um den Ausgangszustand herzustellen. - Fokus auf das Testobjekt: Unit-Tests sollen sich auf eine Sache konzentrieren - hier im Beispiel den Test des InMemoryItemStore. Der InMemoryItemStore verwendet aber eine weitere Komponente IdGenerator, die hier indirekt mit getestet wird - das ist kein sauberer Unit-Test! Eigentlich hätte ich den IdGenerator mocken müssen, so dass ich auch die saveItem Methode hätte testen können. Da ich Mocks aber in einem künftigen Blog-Artikel vorstellen möchte, bitte ich hier um Nachsicht.
- Vollständigkeit: Unit-Tests bestehen aus positiven, negativen und Exception-Tests. Hier im Beispiel sind alle 3 Arten von Tests gezeigt, allerdings nicht für jede Methode - das hole ich weiter unten im Spring Boot Test nach. Außerdem fehlen auch Boundary-Tests bei denen z.B. null als Eingabeparameter verwendet wird.
- Wartbarkeit: Die 4 gezeigten Tests sind denkbar einfach, da sie alle jeweils nur aus ein oder zwei Zeilen Code bestehen. Diese Kürze sorgt für eine gute Wartbarkeit, da jeder Entwickler die Tests direkt versteht. Kommt es zu einem Fehler sollte direkt klar sein, woran es liegt.
- Passende Assertion verwenden: In JUnit 5 wurden viele neue Assertion-Methoden eingeführt. Hier ist es empfehlenswert immer die passende Assertion zu verwenden, so wie ich es oben exemplarisch gezeigt habe. Im Test findItemNegativeTest wäre z.B. die folgende Assertion eine unpassende Wahl, weil wir den Null-Test selbst schreiben müssen - also nicht so machen:
assertTrue(itemStore.findItem("unknown") == null); - Möglichst eine Assertion pro Test: Diese Empfehlung ist gelegentlich schwierig einzuhalten. Wenn viele Assertions benutzt werden, solltet ihr versuchen die Komplexität der zu testenden Methode zu reduzieren, damit weniger Assertion benötigt werden. Das ist dann ein klassisches Refactoring bzw. Clean Code Thema.
JUnit 5 Feature assertThrows
JUnit 5 bietet einige Features, mit denen wir den oben gezeigten Test weiter vereinfachen können. Der Test deleteItemExceptionTest testet, ob eine NoSuchElementException geworfen wird, wenn eine nicht existierende Id als Parameter an die Methode deleteItem übergeben wird. Das zum Prüfen der erwarteten Exception verwendete try-catch Konstrukt macht den Test im Vergleich zu den anderen Tests relativ komplex und "lang".
Dank JUnit 5 und Java 8 können wir die Assertion assertThrows verwenden, um zu überprüfen, ob eine Methode eine bestimmte Exception wirft. Der verbesserte Test sieht dann so aus:
@Test
void deleteItemExceptionTest() {
assertThrows(NoSuchElementException.class,
() -> itemStore.deleteItem("123"));
() -> itemStore.deleteItem("123"));
}
- assertThrows bekommt als ersten Parameter die erwartete Exception Klasse übergeben - hier NoSuchElementException.class. Wenn eine Instanz dieser Klasse geworfen wird, ist unser Test erfolgreich. assertThrows gibt die geworfene Exception zurück, so dass wir z.B. auch ihre Nachricht testen könnten.
- Der 2. Parameter von assertThrows ist ein Java 8 Lambda-Ausdruck:
() -> itemStore.deleteItem("123")
Der Lambda-Ausdruck startet die zu testende Methode deleteItem und übergibt die nicht existierende Id "123", so dass die erwartete Exception geworfen wird.
http://openbook.rheinwerk-verlag.de/javainsel/11_002.html
Buch: Java ist auch eine Insel (zu Java 14)
JUnit 5 Feature @ParameterizedTest
Schauen wir uns noch mal den zuvor gezeigten Test findItemNegativeTest an. Dann fällt auf, dass er zwar schön kurz ist, aber z.B. keine Boundary-Tests macht und generell nur einen einzigen Eingabewert "unknown" für die Methode findItem zum Testen verwendet.
Das könnten wir ändern, indem wir die eine Zeile duplizieren und einfach mit weiteren Eingabewerten testen, z.B. so:
assertNull(itemStore.findItem("unknown123"));
assertNull(itemStore.findItem(""));
assertNull(itemStore.findItem(null));
Copy & Paste ist aber das Gegenteil von Clean Code. Außerdem haben wir dann mehr als eine Assertion (hier 3), was ein weiterer Verstoß gegen die Richtlinien für saubere Unit-Tests ist.
JUnit 5 hat eine Lösung für dieses Problem, parametrisierte Tests:
@ParameterizedTest
@ValueSource(strings = {"unknown", "123"})
@NullAndEmptySource
void findItemNegativeTest(String itemId) {
assertNull(itemStore.findItem(itemId));
}
- @ParameterizedTest ist die Annotation für parametrisierte Tests und wird statt @Test verwendet. Wichtig ist, dass die Testmethode dann auch einen Parameter bekommt, hier ist das:
String itemId - @ValueSource definiert die Liste der Eingabeparameter-Werte, mit denen der parametrisierte Test ausgeführt werden soll. Hier sind 2 Strings definiert, so dass der findItemNegativeTest 2 Mal gestartet wird - einmal mit "unknown" und einmal mit "123". Man kann beliebig viele Eingabeparameter Testwerte definieren - für jeden Wert wird der Test einmal ausgeführt.
- @NullAndEmptySource ruft den parametrisierten Test auf und übergibt einmal den Wert null und einmal einen leeren String "". Statt dem leeren String würde bei einem Eingabeparameter vom Typ Liste oder Array, die leere Liste oder ein leeres Array übergeben.
Wenn die Erstellung der Eingabeparameter zu komplex ist, weil z.B. Instanzen eigener Klassen getestet werden sollen, kann man die Eingabeparameter auch in einer Methode generieren lassen. Im folgenden Beispiel erstelle ich einen Stream von Item Objekten zum mehrfachen Ausführen des saveAndFindItemTest:
static Stream<Item> createTestData() {
Item testData1 = new Item();
testData1.setName("Book");
Item testData2 = new Item();
testData2.setName("Ball");
return Stream.of(testData1, testData2);
}
@ParameterizedTest
@MethodSource("createTestData")
void saveAndFindItemTest(Item item) {
String id = itemStore.saveItem(item);
Item searchedItem = itemStore.findItem(id);
assertEquals(item.getName(), searchedItem.getName());
}
@MethodSource ruft die Methode mit dem in der Annotation definierten Namen auf - hier "createTestData". Wichtig ist, dass die gleichnamige Methode als Rückgabetyp ein Mengentyp wie Stream, Collection, List usw. hat. Hat der Stream dann nur ein Element, ist es nicht sonderlich sinnvoll, aber valide.
Die createTestData Methode ersetzt das Erstellen von Testdaten in der mit @BeforeEach annotierten setup Methode, siehe weiter oben das erste Code-Beispiel.
Spring Boot Test für ItemPersistenceService
Der Code den wir bisher getestet haben, ist Teil meines Spring Beans Demo Projektes - es ist also eine Spring Anwendung, die mit Spring Boot aufgesetzt wurde. Spring Boot bietet für solche Projekte die @SpringBootTest Annotation, mit deren Hilfe unter anderem der Spring IoC Container gestartet und die Beans erstellt werden.
Weitere Details zu Beans und Spring IoC findet ihr hier: kernkonzepte-von-spring-beans-und.html
InMemoryItemStoreJunitTest verwendet nur JUnit und nicht Spring, daher testen wir hier die Implementierungsklasse InMemoryItemStore. InMemoryItemStore implementiert das Interface ItemPersistenceService. Mit dem Spring Boot Test können wir uns die Implementierung des Interfaces ItemPersistenceService per Field Injection injizieren lassen und dann einfach gegen das Interface testen.
@SpringBootTest(classes =
{ InMemoryItemStore.class, IdGenerator.class })
class ItemPersistenceServiceSpringBootTest {
@Autowired ItemPersistenceService itemStore;
static Stream<Item> createTestData() {...}
@ParameterizedTest
@MethodSource("createTestData")
void saveAndFindItemTest(Item item) {...}
@ParameterizedTest
@ValueSource(strings = {"unknown", "123"})
@NullAndEmptySource
void findItemNegativeTest(String itemId) {...}
@ParameterizedTest
@MethodSource("createTestData")
void deleteItemPositiveTest(Item item) {
String id = itemStore.saveItem(item);
itemStore.deleteItem(id);
assertNull(itemStore.findItem(id));
}
@ParameterizedTest
@ValueSource(strings = {"unknown", "123"})
@NullAndEmptySource
void deleteItemExceptionTest(String itemId) {...}
}
Anmerkung: Ich habe Testcode durch "..." ersetzt, wenn ich ihn in vorherigen Abschnitten bereits gezeigt habe.
- Wiederverwendung: Wie schon zuvor erwähnt, testen wir im Spring Boot Test das ItemPersistenceService Interface anstelle der Implementierungsklasse InMemoryItemStore. Sollten wir künftig eine Implementierung des ItemPersistenceService Interfaces schreiben, die Items in einer Datenbank speichert, so können wir diesen Test einfach wiederverwenden.
- Wartbarkeit: Das Instanziieren unseres Test-Objektes (siehe @BeforeEach annotierte setup Methode im JUnit 5 Test Abschnitt) macht Spring für uns, so dass wir uns diesen Code sparen können. Um auf die zu testende Bean zugreifen zu können, müssen wir sie nur aus dem Spring IoC Container injizieren lassen. Das habe ich mit Field Injection so gemacht:
@Autowired ItemPersistenceService itemStore; - Schnelle Tests: Clean Code fordert schnelle Tests! Damit auch dann alle Tests ausgeführt werden, wenn es im Projekt eng wird. Aus meiner Sicht sind schnelle Tests immer wichtig. Wenn es 5 Minuten dauert bis alle Unit-Tests ausgeführt sind, Commiten einige Entwickler ihre Code-Änderungen bevor sie die Tests ausgeführt haben. Die CICD Pipeline führt dann (hoffentlich) alle Tests aus. Dennoch kann es einen anderen Entwickler stören, der dann diesen ungetesteten, evtl. fehlerhaften Code zufällig ausgecheckt hat.
@SpringBootTest lädt per Default den kompletten Spring Kontext, wenn nichts anderes definiert ist. In größeren Projekten kann das Laden des kompletten Spring Kontextes viele Sekunden dauern, so dass wir bei der Ausführung aller JUnit Tests in den Bereich der kritischen 5 Minuten kommen können. Daher beschränke ich den Spring Kontext in meinen Spring Boot Tests auf die notwendigen Implementierungsklassen. Das kann man tun, indem man den Parameter classes der @SpringBootTest Annotation verwendet. Für meinem Test benötige ich eine Instanz des InMemoryItemStore und eine Instanz des IdGenerator:@SpringBootTest(classes ={ InMemoryItemStore.class, IdGenerator.class })
Da mein Demo Projekt ziemlich klein ist, konnte ich keinen signifikanten Unterschied in den Ausführungszeiten des JUnit 5 Tests, aus dem vorherigen Abschnitt, im Vergleich zum Spring Boot Test feststellen.
Fazit & Ausblick
Den Code bzw. die hier gezeigten JUnit Tests findet ihr in diesem GitHub Repository:
Ich habe euch hier gezeigt, wie leicht verständliche und gut wartbare JUnit Tests aussehen können.
Falls ihr noch mit JUnit 4 testet, hoffe ich, dass ich euch mit den gezeigten JUnit 5 Features zu einem Versionswechsel motivieren konnte. JUnit 5 erleichtert das Schreiben besser wartbarer Tests.
Falls ihr noch mit JUnit 4 testet, hoffe ich, dass ich euch mit den gezeigten JUnit 5 Features zu einem Versionswechsel motivieren konnte. JUnit 5 erleichtert das Schreiben besser wartbarer Tests.
Spring Boot Tests helfen beim Testen von Spring Anwendungen.
Von Spring gibt es noch viele weitere tolle Features, wie zum Beispiel Spring MockMvc, die ich euch gerne in künftigen Blog-Artikeln vorstelle.
Von Spring gibt es noch viele weitere tolle Features, wie zum Beispiel Spring MockMvc, die ich euch gerne in künftigen Blog-Artikeln vorstelle.
Mocks sind ein weiteres wichtiges Thema beim Testen - hinterlasst mir gerne ein Kommentar für welche Test-Themen ihr euch interessiert, damit ich das in künftigen Blog-Posts berücksichtigen kann.
Kommentare
mir gefällt dein Artikel sehr gut. Ich selbst bin auch ein großer Fan der Clean Code Serie und denke, dass Tests genau so sauber sein sollten wie der Code selbst. Allerdings hätte ich zwei Anmerkungen:
Gegen Ende deines Artikels schreibst du, die Ausführung von 10 Spring Boot Tests könnte bei Kontext-Ladezeiten von etwa 30 Sekunden, in Wartezeiten von bis zu 5 Minuten resultieren. Verstehe ich das richtig, dass hiermit gemeint ist, dass für jeden Test ein neuer Spring Kontext erzeugt wird, für den 30 Sekunden einkalkuliert werden? Ich dachte Spring ist in der Lage den Kontext für mehrere Tests, des gleichen Spring Profiles, wiederzuverwenden... (https://docs.spring.io/spring-framework/docs/current/reference/html/testing.html#testing-ctx-management)
Des weiteren hinterfrage ich, ob es wirklich die beste Lösung ist, die InMemoryItemStore Klasse in einem SpringBootTest zu testen. Laut meinem Verständnis wäre hier kein Integrationstest notwendig. Schließlich kann die Klasse ja vom Spring Kontext abgekapselt werden und somit isoliert getestet werden. Vielleicht wäre hier der Einsatz von Mocking sinnvoller (wie du zu Anfang ja auch beschreibst, um die Abhängigkeit zum IdGenerator aufzulösen). Ich denke auch, dass das Testen gegen eine Schnittstelle problematisch ist, wenn man zum Beispiel Metriken wie die Branchcoverage berücksichtigen möchte.
Vielleicht könnte ein (abgewandelter) SpringBootTest !zusätzlich! zum SpringBoot-freien-Test verwendet werden, um zu testen, dass z.B. der Dependency Graph richtig aufgesetzt wurde und um den Blackbox Test (Test gegen die Schnittstelle) auszuführen.
Ich hoffe, dass meine Kritik nicht negativ aufgegriffen wird. Ich finde deinen Beitrag wirklich interessant, weil er die wichtigsten Punkte des Clean Code Testens prägnant darstellt. Vielmehr möchte ich hiermit eine Diskussion starten, um die vorgestellten Prinzipien aus einem anderen Blickwinkel zu reflektieren. :)
Viele Grüße
David
vielen Dank für Deine guten Anmerkungen.
Bezüglich meiner Kontext-Ladezeiten Kalkulation hast Du recht.
Spring hat ein Kontext-Management und Caching,
so dass unter bestimmten Bedingungen der komplette Kontext oder zumindest Teile davon wiederverwendet werden.
Wenn man in meinem GitHub Demo-Projekt (https://github.com/elmar-brauch/beans/tree/master/src/test)
die Testklasse ItemPersistenceServiceSpringBootTest einfach kopiert und dann alle Tests zusammen ausführt,
sieht man, dass der Kontext nur einmal erstellt wird und die Ausführung der Kopie viel schneller geht.
Wenn man in der kopierten Klasse den Kontext verändert,
so dass die beiden Klassen unterschiedliche SpringBootTest Annotationen haben:
@SpringBootTest
class ItemPersistenceServiceSpringBootTest2 {
@SpringBootTest(classes = {InMemoryItemStore.class, IdGenerator.class})
class ItemPersistenceServiceSpringBootTest { ...
Sieht man, dass der Spring Kontext 2 Mal geladen wird.
Aber auch dann ist die Ausführung durch das Spring Caching beschleunigt.
Die Beschreibung der Ladezeiten-Kalkulation in meinem Artikel muss ich also korrigieren.
Das Testen der InMemoryItemStore Klasse in einem SpringBootTest ist nicht nötig,
ein normaler Test ist vollkommen ausreichend.
Ich kann also auch hier Deinen Anmerkungen folgen und stimme Dir zu.
Im Blog-Artikel ging es mir aber darum zu zeigen,
wie man aus dem normalen Test einen SpringBootTest machen kann.
Daher habe ich mich für die beiden gezeigten Formen entschieden und jeweils eine eigene Testklasse geschrieben.
In der Praxis würde ich nur eine Testklasse erstellen.