Unit-Tests richtig schreiben: Naming, Struktur und Coverage

1. Übersicht

Ein guter Unit-Test ist nicht nur ein Test, der „grün” wird. Er muss lesbar und wartbar sein, und wenn er fehlschlägt, sollte er dir sofort zeigen, wo das Problem liegt, ohne dass du stundenlang debuggen musst. Dieser Artikel behandelt drei Säulen: Test-Namen schreiben, die sich selbst erklären, Tests nach dem AAA-Muster (Arrange-Act-Assert) strukturieren, und Coverage richtig bewerten, um nicht in die Falle „100% Coverage, aber nutzlos” zu tappen.

Danach hast du eine konkrete Checkliste, um deine Tests vor dem Erstellen eines Pull Requests selbst zu überprüfen.

2. Erforderliche Dependencies

Die Beispiele in diesem Artikel verwenden JUnit 5 und AssertJ. In Spring-Boot-Projekten sind beide bereits in spring-boot-starter-test enthalten.

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

3. Test benennen: Der Name sollte eine Geschichte erzählen

Der Test-Name ist die erste Dokumentation, die andere lesen, wenn ein Test fehlschlägt. Ein guter Name beantwortet drei Fragen: Was wird getestet, unter welcher Bedingung und was wird erwartet.

3.1. Schema methodName_condition_expectedResult

class OrderServiceTest {

    @Test
    void calculateTotal_whenCartIsEmpty_returnsZero() {
        OrderService service = new OrderService();
        Order order = new Order(List.of());

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.ZERO);
    }

    @Test
    void calculateTotal_whenCartHasDiscountedItems_appliesDiscount() {
        OrderService service = new OrderService();
        Order order = new Order(List.of(
                new Item("SKU-1", BigDecimal.valueOf(100), 0.1)
        ));

        BigDecimal total = service.calculateTotal(order);

        assertThat(total).isEqualTo(BigDecimal.valueOf(90.0));
    }
}

Erklärung:

  • Der Name beginnt mit der zu testenden Methode (calculateTotal), dann folgt when... mit der Bedingung und abschließend das erwartete Ergebnis.
  • Wenn ein Test im CI-Report fehlschlägt, erkennst du sofort, welcher Logik-Branch betroffen ist — du musst den Code nicht öffnen.

Hinweis: Vermeidest Namen wie test1() oder testCalculateTotal(). Sie sagen nichts über den Kontext aus und zwingen den Leser, den Test-Body zu öffnen, um zu verstehen, was getestet wird.

3.2. @DisplayName wenn der Methodenname nicht aussagekräftig genug ist

Bei komplexen Business-Regeln werden lange Methodennamen schwer lesbar. Nutze @DisplayName, um natürlichere Sätze zu schreiben und den Methodennamen kurz zu halten.

@Test
@DisplayName("Wenn der Kunde VIP ist und die Bestellung über 1 Million liegt, wird zusätzlich 5% rabattiert")
void calculateTotal_vipCustomerLargeOrder_extraDiscount() {
    // ...
}

Die Ausgabe bei der Ausführung mit Maven Surefire oder in der IDE zeigt den @DisplayName-Text statt des Methodennamens an, was die Reports lesbarer macht.

4. AAA-Struktur: Arrange – Act – Assert

Jeder Test sollte drei klare Teile haben, in der richtigen Reihenfolge, ohne Vermischung. Das hilft dem Leser, schnell zu erfassen: welche Zeilen bereiten Daten vor, welche rufen das Verhalten auf, welche überprüfen das Ergebnis.

@Test
void withdraw_whenBalanceIsSufficient_deductsAmount() {
    // Arrange
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));

    // Act
    account.withdraw(BigDecimal.valueOf(200));

    // Assert
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(300));
}

Erklärung:

  • Arrange erstellt das Objekt Account mit dem anfänglichen Saldo.
  • Act ruft genau eine Aktion auf, die getestet wird (withdraw).
  • Assert überprüft das Ergebnis; mit AssertJ werden Assertions wie fließender Text lesbar.

4.1. Ein Test, eine Aktion

Je mehr ein Test „tut”, desto schwerer ist es zu debuggen, wenn er fehlschlägt. Vermeidest, Tests so zu schreiben:

@Test
void accountOperations() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(500));
    account.deposit(BigDecimal.valueOf(100));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(600));

    account.withdraw(BigDecimal.valueOf(200));
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));

    account.withdraw(BigDecimal.valueOf(1000)); // Fehler: nicht genug Geld
    assertThat(account.getBalance()).isEqualTo(BigDecimal.valueOf(400));
}

Wenn die erste Assertion fehlschlägt, laufen die beiden nachfolgenden Aktionen nicht aus — du verlierst Informationen. Teile in drei separate Tests auf: deposit_..., withdraw_sufficientBalance_..., withdraw_insufficientBalance_throwsException.

4.2. Exception-Tests müssen den richtigen Typ und die Nachricht prüfen

@Test
void withdraw_whenBalanceIsInsufficient_throwsIllegalStateException() {
    Account account = new Account("ACC-001", BigDecimal.valueOf(100));

    assertThatThrownBy(() -> account.withdraw(BigDecimal.valueOf(500)))
            .isInstanceOf(IllegalStateException.class)
            .hasMessageContaining("Insufficient balance");
}

assertThatThrownBy ruft die Aktion auf und erfasst die Exception in einem Schritt. Verwende nicht das alte @Test(expected = ...)-Pattern, da es es nicht erlaubt, danach die Nachricht oder den State zu prüfen.

5. @ParameterizedTest verwenden, um Code-Wiederholung zu vermeiden

Wenn mehrere unterschiedliche Eingaben die gleiche Logik prüfen, nutze parameterisierte Tests statt mehrere Methoden zu kopieren.

@ParameterizedTest
@CsvSource({
    "0, 0",
    "100, 10",
    "1000, 100",
    "-50, 0"
})
void calculateShippingFee_variousOrderAmounts_returnsExpectedFee(
        BigDecimal orderAmount, BigDecimal expectedFee) {

    ShippingCalculator calculator = new ShippingCalculator();

    BigDecimal fee = calculator.calculateFee(orderAmount);

    assertThat(fee).isEqualTo(expectedFee);
}

Erklärung:

  • @CsvSource liefert Input/Output-Paare; jede Zeile ist ein unabhängiger Test-Fall.
  • Der Report zeigt jeden Fall separat; wenn ein Fall fehlschlägt, siehst du sofort welcher, ohne zu raten.
calculateShippingFee_variousOrderAmounts_returnsExpectedFee(BigDecimal, BigDecimal)
  ✓ [1] 0, 0
  ✓ [2] 100, 10
  ✗ [3] 1000, 100
  ✓ [4] -50, 0

6. Mock richtig einsetzen, nicht übertreiben

Nutze Mock für externe Abhängigkeiten (Datenbank, HTTP-Client, andere Services), nicht für das Objekt selbst, das getestet wird.

@ExtendWith(MockitoExtension.class)
class PaymentServiceTest {

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private PaymentService paymentService;

    @Test
    void processPayment_whenGatewayApproves_marksOrderAsPaid() {
        Order order = new Order("ORD-01", BigDecimal.valueOf(200));
        when(paymentGateway.charge(order.getAmount())).thenReturn(ApprovalResult.approved());

        PaymentResult result = paymentService.processPayment(order);

        assertThat(result.isSuccess()).isTrue();
        verify(paymentGateway).charge(BigDecimal.valueOf(200));
    }
}

Erklärung:

  • @Mock erstellt ein Mock-Objekt für PaymentGateway — was nach außen aufgerufen wird (könnte eine echte API sein).
  • @InjectMocks injiziert das Mock in PaymentService, das getestet wird.
  • verify bestätigt, dass charge mit den richtigen Parametern aufgerufen wurde, nicht nur das Rückgabe-Ergebnis geprüft.

Hinweis: Wenn du mehr als 3-4 Abhängigkeiten für einen Test mocken musst, ist das ein Zeichen, dass die Klasse zu viel tut. Ziehe in Betracht, die Klasse zu teilen, bevor du die Tests splittest.

7. Coverage: nur eine Orientierungshilfe, kein Ziel

Hohe Coverage bedeutet nicht automatisch gute Tests. Der folgende Test erzielt 100% Line-Coverage für die Methode divide, erkennt aber einen Bug nicht:

public int divide(int a, int b) {
    return a / b;
}
@Test
void divide_returnsQuotient() {
    Calculator calculator = new Calculator();

    int result = calculator.divide(10, 2);

    assertThat(result).isEqualTo(5);
}

Dieser Test durchläuft alle Code-Zeilen (100% Coverage), testet aber nicht den Fall b = 0 — wo in Production eine ArithmeticException geworfen wird. Coverage misst nur welche Zeilen ausgeführt wurden, nicht ob sie richtig getestet wurden.

7.1. Nutze Coverage, um blinde Flecken zu finden, nicht für schöne Reports

<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.12</version>
    <executions>
        <execution>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
    </executions>
</plugin>

Führe mvn test aus; JaCoCo generiert den HTML-Report unter target/site/jacoco/index.html. Nutze den Report um zu antworten: „Welche Branches sind noch nicht durch Tests abgedeckt?“, und frage dich dann: „Sind diese Branches logisch wichtig?” — folge nicht blind dem Prozentsatz.

Element           Missed Instructions  Cov.
Calculator        12 of 40             70%
  divide()        4 of 8               50%  ← kein Test für b=0

7.2. Edge Cases sind wichtiger als der Prozentsatz

Füge einen Test für den oben übersehenen Fall hinzu:

@Test
void divide_whenDivisorIsZero_throwsArithmeticException() {
    Calculator calculator = new Calculator();

    assertThatThrownBy(() -> calculator.divide(10, 0))
            .isInstanceOf(ArithmeticException.class);
}

Eine einzelne Test-Zeile hier hat mehr Wert als fünf weitere Tests mit den gleichen normalen Cases zu schreiben, nur um den Coverage-Prozentsatz zu erhöhen.

8. Checkliste zum Selbst-Review von Tests

Bevor du einen Pull Request erstellst, überprüfe selbst:

  • Sagt der Test-Name klar aus, welche Bedingung und welches Ergebnis erwartet wird, oder ist es nur test1, testX?
  • Hat jeder Test genau eine Aktion, einen Grund fehlzuschlagen?
  • Gibt es Tests für Edge Cases: null, empty, 0, negative Werte, Obergrenzen?
  • Gibt es Tests für Fehler (Exceptions) oder nur für den Happy Path?
  • Wo ist die Coverage niedrig — ist wichtige Logik übersehen oder sind es nur Getter/Setter, die nicht getestet werden müssen?

9. Fazit

Ein guter Unit-Test ist ein Test, der dir beim Fehlschlag sofort zeigt, wo das Problem liegt, ohne dass du debuggen musst. Benenne nach dem Muster method_condition_expected, halte die AAA-Struktur sauber mit einer Aktion pro Test, und nutze Coverage als Werkzeug zur Fehlersuche, nicht als Ziel — das sind drei einfache Prinzipien, aber sie reichen aus, um die Qualität deiner Test-Suite erheblich zu verbessern. Der schwierigste Teil ist nicht, Assertions zu schreiben, sondern sich die Zeit zu nehmen, über Edge Cases nachzudenken, denen dein Code in Production begegnen könnte.

testingjunitjavabest-practicesspring-boot