Testing in Java: eine Überblick

Dieser Blogbeitrag ist eine kurze Anleitung für Test-Prinzipien und Testing mit Spring Boot.

Quellen: Wikipedia (https://shre.ink/rD2y), Udemy (https://shre.ink/rD2z)

Wie ist das Testing in meinem Alltag so wichtig geworden?

Neun Jahre lang habe ich als IT-Generalist gearbeitet. Meine täglichen Aufgaben bestanden aus Helpdesk-Support, Systemverwaltung und Software-Engineering. Mein Wunsch war es jedoch, nahezu 100% meiner Zeit mit Softwareentwicklung und mit modernsten Technologien zu verbringen und seit etwa mehr als einem Jahr konnte ich mein persönliches Ziel erreichen. Heute arbeite ich als Java-Softwareentwickler in einem Scrum-Team bei einem Unternehmen, das moderne Technologien einsetzt und diverse komplexe Applikationen betreut. Dazu gehören aber neue Herausforderungen und eine von denen ist das Testing in Java-Softwareentwicklung mit unterschiedlichen Bibliotheken, Tools und Konzepte.

Die Bedeutung von Testing hat sich für mich komplett gewandelt: Früher erschien mir Testing als ein zusätzlicher Aufwand, der erledigt werden könnte, sofern genügend Zeit übrig blieb. Heute bedeutet Testing in meinem Alltag der wichtigste Teil der Softwareentwicklung: Beim Programmieren eines neuen Codes oder beim Anpassen eines bestehenden Codes, muss ein Test dazu gehören, ansonsten verbietet SonarQube (die Plattform für statische Analyse und Bewertung der technischen Code-Qualität), dass mein Pull-Request mit dem Master-Brunch gemerget wird. Von da an entstand die Notwendigkeit, den Code auf einer unterschiedlichen Art und Weise zu testen, deshalb möchte ich mit diesem Blog meine Erfahrung mit Testing in Java Spring Boot Applikationen sowie meine Vorschläge für Java-Entwickler und -Enthusiasten teilen.

Die Testing-Prinzipien

TDD (Test-Driven Development) ist eine agile Softwareentwicklungsmethode, die den Entwicklungsprozess durch die Erstellung von Tests vorantreibt. Der traditionelle Entwicklungsprozess ohne TDD ist für viele Entwickler vertraut, und ein Mentalitätswechsel hin zu TDD erfolgt nicht über Nacht, sondern schrittweise. Die Kenntnis der Prinzipien und Vorteile von TDD ist wesentlich für die Entwicklung einer TDD-Mentalität. Mit der Zeit und durch Erfahrung wurden verschiedene Testing-Prinzipien definiert. Zwei davon sind besonders wichtig für die Testprogrammierung.

  • FIRST (Fast, Isolated, Repeatable, Self-verifying, Timely) erläutert wie gute Tests funktionieren sollten:
    1. Fast: Tests sollten schnell ausgeführt werden, damit sie effizient in den Entwicklungsprozess integriert werden
    2. Isolated/Independent: Jeder Testfall sollte unabhängig von anderen Tests sein, um sicherzustellen, dass ein Fehler in einem Testfall nicht zu Fehlern in anderen führt.
    3. Repeatable: Tests sollten jederzeit wiederholbar sein, unabhängig von Umgebungsbedingungen oder externe Faktoren.
    4. Self-Validating (Selbstvalidierend): Ein Testfall sollte eindeutig bestehen oder scheitern, ohne manuelle Überprüfung erforderlich zu machen.
    5. Timely (Zeitnah): Tests sollten zeitnah geschrieben werden, idealerweise bevor der zu testende Code implementiert wird.
  • TDD (Test Driven Development): Martin R. (2009) legt folgende Gesetzte des TDD nach The three Laws of TDD fest:
    1. „First Law — You may not write production code until you have written a failing unit test.
    2. Second Law — You may not write more of a unit test than is sufficient to fail, and not compiling is failing.
    3. Third Law — You may not write more production code than is sufficient to pass the currently failing test.“

Test-Struktur

Organisieren Sie Ihre Tests mit BDD (Behavior Driven Development) und verbessern Sie die Lesbarkeit von Tests. Die Bedienung ist:

  • Given: der Bereich, wo der Code für den Test definiert wird
  • When: die Aktion, die Methode, die durchgeführt wird
  • Then: das erwartete Resultat

Erstellen Sie eine Richtlinie für die Nomenklatur von Tests (das soll mit dem Team abgestimmt werden), z.B. eine Test-Methode kann heissen:

  1. Wenn der Test erfolgreich ist: Name der Methode + Resultat
    • submit_shouldSendDataSuccessfully()
    • submit_throwsMyCustomException()
@Test
void submit_error_exception() throws Exception {
  // given
  final String objectKey = "anyObjectKey";
  when(minioClient.getObject(any())).thenThrow(new IOException("test exception cast"));
        
  // when
  CustomServiceException result = assertThrows(CustomServiceException.class, () -> customService.submit(objectKey));
        
  // then
  assertEquals("An error occurred while processing file with key anObjectKey", result.getMessage());
}
Test-Methode mit einer sinnvoller Nomenklatur und BDD-Format

Unittests

JUnit 5

Der erste Schritt zu einer soliden Teststrategie ist die Verwendung von Unittests. JUnit 5 ist das Standard-Testframework für Spring-Applikationen (seit Spring Boot 2.2 wird JUnit 5 verwendet). Mit diesen können diverse Unittest-Fälle geschrieben werden, um das Verhalten einzelner Komponenten oder Codeeinheiten zu validieren. In Spring Boot ist die Integration mit JUnit nahtlos. Konzentrieren Sie sich darauf, Ihre Geschäftslogik, Controller und Services mit JUnit zu testen, um sicherzustellen, dass jede Einheit Ihrer Anwendung wie erwartet funktioniert.

Immer wenn eine neue, unbekannte Methode verwendet werden muss, ist JUnit, ein sehr gutes Tool, um das Resultat zu testen. Zum Beispiel, wenn die java.time.LocalDate von Java 8 zum ersten Mal verwendet wird, sollten Sie zuerst die dazugehörigen Unittests erstellen, die mindestens folgende Szenarien abdecken:

  1. Fehlerhaft: ein Szenario kann z.B. eine Exception werfen (durch assertThrows von JUnit 5):
    @Test // JUnit Test Methode
    public void parseInt_throwsNumberFormatException() {
      var exception = assertThrows(NumberFormatException.class, () -> {
        Integer.parseInt("1a");
      });
    
      var expectedMessage = "For input string";
      var actualMessage = exception.getMessage();
    
      assertTrue(actualMessage.contains(expectedMessage));
    }
  2. Erfolgreich: ein Szenario funktioniert reibungslos, z.B.:
    @Test
    public void parseInt_throwsNumberFormatException() {
      // act
      var number = Integer.parseInt("1");
    
      // assert
      assertEquals(1, number);
    }

Integration Tests in Spring Boot

Integrationstests stellen sicher, dass verschiedene Komponenten einer Anwendung nahtlos zusammenarbeiten. Ein sehr starkes Feature von Spring Boot ist die Annotation @SpringBootTest. Dazu existieren vier mögliche Konfigurationen für eine Web-Umgebung (webEnvironment Modus):

  • RANDOM_PORT: verwendet irgendeinen Port und startet den Server
  • DEFINED_PORT: verwendet den definierten Port (server.port) aus der application.properties Datei und startet einen embedded Server
  • MOCK: Standard-Option, ein Server wird für Tests gemockt
  • NONE: Spring-Beans werden geladen, aber kein Server wird erstellt
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT)
public class MyTest {
...

Mit @SpringBootTest können diverse Integrationstests durchgeführt werden, ohne zusätzliche Installation oder Konfiguration, wie z.B. Datenbank oder Server.

Andere Spring Boot Annotations für Testing sind:

    • @WebMvcTest: hat den Fokus auf Spring MVC (Model View Controller) Komponenten und wird stark verwendet, um die Controllers zu testen
    • @DataJpaTest: ist besonders gut für Integration Tests mit JPA. Queries können getestet werden und keinen zusätzlichen Aufwand mit Datenbank-Einrichtung und/oder Clean-up wird benötigt.
    • @MockBean: das ist ähnlich wie @Mock von Mockito, aber es mockt Objekte, die in der Applikation als Spring Bean definiert wurden.
    • @WebTestClient: WebClient ist der Nachfolger von RestTemplate als Client, der REST-Aufrufe (Sync und Async) machen kann. Spring bietet dafür ebenfalls einen WebTestClient, der WebClient simuliert kann.

Mocking mit Mockito

Mocking kann nicht nur in isolierten Unittests verwendet werden, um externe Abhängigkeiten zu simulieren, sondern auch in Integrationstests, um externe Abhängigkeiten zu steuern und zu überwachen. Das bekannteste Mocking-Tool in Java ist Mockito. Wie in JUnit sollte Mockito oft im Alltag verwendet werden.

Die Hauptpunkte von Mockito sind:

    • Annotation @Mock: zum Erstellen von Mock-Objekten
    • Annotation @InjectMocks: zum Erstellen und Einfügen von Mock-Objekten. Die Abhängigkeiten eines Objekt mit InjectMocks müssen als Mock deklariert werden.
    • Annotation @Spy: zum Erstellen einer echten Instanz eines Objekts und gleichzeitig überwachen. Es gibt aber diverse Gründe, um @Spy zu vermeiden:
      1. Komplexität und Nebenwirkungen: Die Verwendung von @Spy kann die Testlogik komplexer machen, da Sie echte Implementierungen verwenden und möglicherweise bestimmte Teile davon überwachen. Dies kann zu unerwarteten Nebenwirkungen führen und die Tests schwieriger zu verstehen machen
      2. Echte Implementierungsdetails: Wenn Sie eine echte Implementierung verwenden, können sich Änderungen in dieser Implementierung auf Ihre Tests auswirken. Unit-Tests sollen unabhängig von der tatsächlichen Implementierung sein, um robuste und wartbare Tests zu gewährleisten
      3. Besser geeignete Alternativen: Es ist normalerweise besser, reine Mock-Objekte zu verwenden (mit @Mock), um die Isolation der Tests zu gewährleisten. Mock-Objekte bieten eine klare Abgrenzung und erleichtern das Definieren von erwartetem Verhalten
      4. Testisolationsprinzipien: Die Verwendung von @Spy kann dazu führen, dass Tests gegen echte Implementierungen ablaufen, was gegen das Prinzip der Testisolierung verstossen könnte. Tests sollten idealerweise nur die Einheit testen, für die sie geschrieben wurden, ohne von externen Implementierungsdetails abhängig zu sein
      5. Potenziell schlechte Performance: Da ein @Spy eine echte Instanz verwendet und bestimmte Methoden überwacht, kann dies zu einer schlechteren Leistung führen, insbesondere wenn die echte Implementierung zeitaufwändige Operationen durchführt.
    • Funktion when(): zum Simulieren von diversen Szenarien (inklusive Durchführung von Exceptions) bei Methoden, die ein Resultat retournieren
    • Funktion verify(): when kann Methoden nicht abdecken, die kein Resultat retournieren. Aber dafür kann verify diverse Kriterien in einer Methode überprüfen.

Was Mockito nicht kann, ist: „static„, „private“ und „final“ Methoden zu abdecken. Mockito wurde aber gebaut, um Best-Practices für Mocking-Tests zu verwenden und static/private/final Methoden sollten normalerweise nicht gemockt werden. Falls der Bedarf trotzdem besteht, existiert dafür das Tool PowerMock:

Beispiel für static Methoden: PowerMockito.mockStatic(Utility.class)
Beispiel für private Methoden: Whitebox.invokeMethod(systemUnderTest, "privateMethodName")
Mockito-Beispiel:

@ExtendWith(MockitoExtension.class)
class ServiceDatabaseIdTest {

@Mock
Database databaseMock;

@Test
void ensureMockitoReturnsTheConfiguredValue() {
 // define return value for method getUniqueId()
 when(databaseMock.getUniqueId()).thenReturn(42);

 Service service = new Service(databaseMock);
 // use mock in test....
 assertEquals(service.toString(), "Using database with id: 42");
 }

}

Zusätzliche Java-Bibliotheken

JUnit, Mockito, Spring Boot Tests werden am meisten in Alltag verwendet, aber es gibt diverse andere Java-Bibliotheken, die einfach in Java-Projekt als „dependency“ eingefügt werden und spezifische Testszenarios besser abdecken können:

  • MockWebServer: kann gut testen, wie HTTP-Requests und -Responses gemacht werden. Das ist ein sehr beliebtes Tool für Tests mit Spring WebClient, da Mockito nicht ideal dafür ist.
  • Awaitility: bietet unterschiedliche Möglichkeiten für asynchrone Tests (Message Brokers, Threads, Timouts, Nebenläufigkeit, usw.)

Weitere Empfehlungen

Basierend auf was ich im letzten Jahr gelernt habe, empfehle ich gerne folgende Bücher und Online-Kurse:

Beitrag teilen

Francisco Mariani Guariba Filho

Software Entwickler bei Suva, Student bei HSLU

Alle Beiträge ansehen von Francisco Mariani Guariba Filho →

Schreibe einen Kommentar