In diesem Dokument gehe ich davon aus, dass die Grundlagen von Unittest bekannt sind und bekannt ist wie man im genutzten Framework Unittests schreibt. Die Beispiele in diesem Dokument sind Java geschrieben worden, können aber in jede andere Sprache übersetzt werden. Die hier beschriebenen Best Practices sind unabhängig von der genutzten Programmiersprache und gelten generell.
Es ist erschreckend einfach schlechte Unittests zu schreiben, die einem Projekt wenig Mehrwert bringen, die Kosten für Änderungen (costs of code changes) an der Code Basis aber astronomisch werden lassen. |
private static int callCounter = 0;
private final Logger logger;
private final StringUtil util;
public IntroExample(Logger logger, StringUtil dependency) {
this.logger = logger;
this.util = dependency;
}
public static int getCallCounter() {
return callCounter;
}
public String wrapTransform(String input) {
callCounter++;
final String transformed = util.transform(input);
logger.info("transform: " + input + " into:" + transformed);
logger.info("call counter: " + callCounter);
return transformed;
}
interface StringUtil {
String transform(String information);
}
@Test
void test0() {
Logger logger = Mockito.mock(Logger.class);
IntroExample.StringUtil stringUtil = Mockito.mock(IntroExample.StringUtil.class);
IntroExample example = new IntroExample(logger, stringUtil);
Mockito.when(stringUtil.transform(ArgumentMatchers.eq("input 1"))).thenReturn("test 1");
String transformed = example.wrapTransform("input 1");
int callCounter = IntroExample.getCallCounter();
Mockito.verify(logger, Mockito.atLeastOnce()).info(ArgumentMatchers.anyString());
Assertions.assertNotNull(transformed);
Assertions.assertNotEquals(callCounter, 0);
}
@Test
void test1() {
Logger logger = Mockito.mock(Logger.class);
IntroExample.StringUtil stringUtil = Mockito.mock(IntroExample.StringUtil.class);
IntroExample example = new IntroExample(logger, stringUtil);
Mockito.when(stringUtil.transform(ArgumentMatchers.eq("input 2"))).thenReturn("result 2");
String transformed = example.wrapTransform("input 3");
int callCounter = IntroExample.getCallCounter();
Mockito.verify(logger, Mockito.atLeastOnce()).info(ArgumentMatchers.anyString());
Assertions.assertNull(transformed);
Assertions.assertNotEquals(callCounter, 0);
}
1. Unittests verhindern keine Bugs
In computer programming, unit testing is a software testing method by which individual units of source code—sets of one or more computer program modules together with associated control data, usage procedures, and operating procedures—are tested to determine whether they are fit for use. |
Unittests beweisen nicht die Abwesenheit von Fehlern, sondern nur deren Anwesenheit bei fehlschlagenden Tests. |
Es ist wichtig die Motivation hinter Unit testing zu verstehen. Unittests sind kein Weg Bugs in einer Applikation zu finden oder zu verhindern. Per Definition untersucht ein Unittest eine Code-Unit unabhängig und separat. Wenn die Applikation gestartet wird, müssen diese Code-Unit zusammen arbeiten und erst im Zusammenspiel der Code-Unit treten Fehler auf. Mithilfe von Unittest können keine Regression-Bugs gefunden werden. Sicherstellen, dass eine Komponente X und Y unabhängig voneinander korrekt, Unittests sind ein
Test-driven development (TDD) is a software development process relying on software requirements being converted to test cases before software is fully developed, and tracking all software development by repeatedly testing the software against all test cases. This is as opposed to software being developed first and test cases created later. |
Zur Findung von Regression Bugs empfiehlt sich Integration testing zu nutzen.
2. Tipps um gute Unittest zu schreiben
2.1. Immer nur eine Code-Unit testen
Wenn eine Code-Unit getestet wird, kann diese mehrere Use Cases haben. Jeder Use Case sollte in separatem Test behandelt werden. Jeder Test muss unabhängig von anderen Tests sein.
public boolean noneNullEquals(String first, String second) {
if (Objects.isNull(first) || Objects.isNull(second)) {
throw new IllegalArgumentException("Input can't be 'null'!");
}
return first.equals(second);
}
@Test
void firstParameterIsNull() {
assertThrows(IllegalArgumentException.class,() -> exampleOneUnit.noneNullEquals(null, "second"));
}
@Test
void secondParameterIsNull() {
assertThrows(IllegalArgumentException.class,() -> exampleOneUnit.noneNullEquals("first", null));
}
@Test
void bothParametersAreNull() {
assertThrows(IllegalArgumentException.class, () -> exampleOneUnit.noneNullEquals(null, null));
}
@Test
void firstIsEqualToSecond() {
assertTrue(exampleOneUnit.noneNullEquals("equals", "equals"));
}
@Test
void firstIsNotEqualToSecond() {
assertFalse(exampleOneUnit.noneNullEquals("equals", "not equals"));
}
Diese Tests helfen bei Code-Änderungen oder Refactoring, die nicht die funktionalität der Tests betreffen. Ein Ausführen der Tests reicht, um weiterhin eine funktionierende Applikation zu liefern. Zusätzlich führt eine Änderung am Verhalten der Businesslogik dazu, dass einer (oder mehrere) Tests fehlschlagen.
2.2. Auf unnötige Assertions verzichten
Unittests sind dafür gedacht ein spezielles Verhalten zu abzudecken, nicht eine ganze Liste von Beobachtungen, welche in dem Code geschehen.
Versucht nicht alles auf einmal sicherzustellen, fokussiert euch auf das was ihr testet. Andernfalls werdet ihr bei einer kleinen Codeänderung mehrere fehlschlagende Tests aufgrund des gleichen Grundes bekommen. Damit erreicht ihr auf längere Sicht nichts.
2.3. Macht jeden Test unabhängig von allen anderen
Macht keine Kette von Unittests.
Es wird verhindern, dass ihr den Hauptgrund für den Fehler findet und ihr den Code debuggen müsst. Ausserdem erzeugt es Abhängigkeiten, d.h. ihr müsst nach dem ihr 1 Test ändert auch alle darauf aufbauende Tests anpassen.
Wenn möglich benutzt @BeforeEach
und @AfterEach
bzw. @BeforeAll
und @AfterAll
als Vorbereitung (wenn nötig) für
jeden Test. Wenn ihr mehrere verschiedene Dinge für verschiedene Tests vorbereiten müsst, ist es sinnvoll die Tests in
verschieden Klassen zu schieben.
2.4. Simuliert (mocked) alle externen Dienste
Andernfalls testet ihr das Verhalten dieser Dienste mit. Sollte diese Dienste nur online erreichbar sein, funktionieren eure Unittests auch nur online und offline arbeiten ist nicht mehr möglich. Ausserdem können sich durch Status- oder Datenänderungen verschiedene Unittests gegenseitig beeinflussen, was zu falschen fehlschlägen führt.
(Btw. macht es keinen Spass einen Unittest debuggen zu müssen, nur weil ein externer Dienst einen Fehler hat.)
2.5. Tested keine Konfiguration
Per Definition sind Konfiguration nicht Teil des Codes, darum lagern wir sie schließlich in eigene Dateien und in Zeiten von Cloud-Computing in Configuration-Stores aus. Das wichtigste, Konfiguration werden sich zur Laufzeit oder zur Startzeit der Applikation unterscheiden, daher wäre ein Test sinnfrei.
2.6. Benenne deine Unit-Tests klar und einheitlich
Nun, das ist vielleicht der wichtigste Punkt, an den du dich erinnern und dem du weiter folgen solltest. Du musst deine Testfälle danach benennen, was sie tatsächlich tun und testen. Eine Namenskonvention für Testfälle, die Klassennamen und Methodennamen für Testfallnamen verwendet, ist niemals eine gute Idee. Jedes Mal, wenn du den Methoden- oder Klassennamen änderst, wirst du am Ende auch viele Testfälle aktualisieren. Bei Refactoring sollten Änderungen am Test-Code minimal sein.
Wenn deine Testfallnamen jedoch logisch sind, d.h. auf Operationen basieren, müssen Sie fast keine Änderungen vornehmen, da die Anwendungslogik höchstwahrscheinlich gleich bleibt.
Z.B. Testfallnamen sollten wie folgt aussehen:
-
create_employee_with_valid_id
-
create_employee_with_null_id_throws_exception
-
create_employee_with_negative_id_throws_exception
-
create_employee_with_duplicate_id_throws_exception
2.7. Alle Methoden, unabhängig der Sichtbarkeit, sollten korrekte und vollständige Tests haben
Nun, das ist in der Tat umstritten.
Du musst nach den kritischsten Teilen deines Codes suchen und du solltest sie testen, ohne dir Gedanken darüberzumachen, ob sie überhaupt privat sind.
Diese Methoden können bestimmte kritische Algorithmen haben, die von einer oder zwei Klassen aufgerufen werden, aber sie spielen eine wichtige Rolle. Du möchtest sicher sein, dass sie wie vorgesehen funktionieren.
2.8. Verwenden Sie die am besten geeigneten Assertion-Methoden
Es gibt viele Assertion-Methoden, mit denen du in jedem Testfall arbeiten kannst. Verwende die am besten geeignete mit der richtigen Argumentation und Überlegung. Sie sind für einen Zweck da, benutze sie.
2.9. Bringen Sie Assertion-Parameter in die richtige Reihenfolge
Assert-Methoden benötigen normalerweise zwei Parameter. Einer ist der erwartete Wert und der zweite ist der ursprüngliche Wert. Übergib sie nach Bedarf der Reihe nach. Dies hilft bei der korrekten Nachrichtenanalyse, wenn etwas schiefgeht.
2.10. Trenne den Testcode von Produktionscode
Stellen Sie in Ihrem Build-Skript sicher, dass der Testcode nicht mit dem tatsächlichen Quellcode bereitgestellt wird. Es ist Ressourcenverschwendung.
2.11. Erstelle Komponententests, die auf Ausnahmen abzielen
Wenn einige eurer Testfälle erwarten, dass die Ausnahmen von der Anwendung ausgelöst werden. Versuche, das Abfangen einer Ausnahme im Catch-Block zu vermeiden, und versuche die Verwendung der Fail- oder Asset-Methode zu vermeiden.
Wenn eine Methode im Testcode eine Ausnahme auslöst, schreibe keinen catch-Block, nur um die Ausnahme abzufangen und den Testfall nicht zu bestehen. Verwenden Sie stattdessen throws Exception-Anweisung in der Testfall-Deklaration selbst. Ich würde die Verwendung der Exception-Klasse empfehlen und keine bestimmten Unterklassen von Exception verwenden. Dadurch wird auch die Testabdeckung erhöht.
Ausserdem wirft eine Assertion im Regelfall selbst eine Exception im Fehlerfall. Wir wollen den Testcode nicht weiter verwenden daher sind präzise Exception wenig hilfreich.
2.12. Verlasse dich nicht auf indirekte Tests
Gehe nicht davon aus, dass ein bestimmter Testfall auch ein anderes Szenario testet, den dies fügt Mehrdeutigkeit hinzu. Schreibe stattdessen für jedes Szenario einen weiteren expliziten Testfall.
2.13. Integrieren Sie Testfälle mit Build-Skript
Dies sollte selbstverständlich sein. Es ist besser, wenn ihr eure Testfälle mit Build-Skripten integrieren können, damit sie automatisch in deiner Continuous Development Umgebung ausgeführt werden. Dies erhöht die Zuverlässigkeit der Anwendung sowie des Testaufbaus.
2.14. Gebt einen Grund an, wenn ein Test überspringen wird
Ein nicht ausgeführter Test nutzt niemandem etwas. Daher sollte ein Test nur temporär per @Disabled
deaktiviert
werden. Ihr solltet immer einen Grund für das Abschalten des Testes angeben. Nicht nur für euch selbst, sondern auch
für eure Kollegen.
-
@Disabled("temporarily disabled for migration")
-
@Disabled("temporarily disabled to deploy hot fix for bug: #123")
Dauerhaft deaktivierte Test können sicher entfernt werden, deren Nutzen hat sich einfach überlebt und deren Produktionscode mit großer Wahrscheinlichkeit schon geändert.
2.15. Benutzt keine versteckten implizite Tests
when(dependency.someMethod(new Object(), Integer.valueOf("123"))).thenReturn("Foo Bar Baz");
Dieses Antipattern sehe ich in fast allen Projekten, an denen ich arbeite oder gearbeitet habe.
-
Die Erwartung steht sehr weit oben im Test und nicht am Ende.
-
Sollten die Parameter nicht stimmen kommt es zu unerwarteten
NullPointerException
. -
Der Test ist implizit und Tests müssen immer explizit sein um verständlich und wartbar zu sein.
when(dependency.someMethod(any(), any())).thenReturn("Foo Bar Baz");
objectUnderTest.someOtherMethod(123);
verify(dependency).someMethod(any(Object.class), eq(123));