Zum Inhalt
wannacode

Tests mit JUnit

Kurz gesagt: Ein Unit-Test ist Code, der bei jeder Änderung ein kleines Stück prüft, meist eine Methode; in Java schreibst du ihn mit JUnit, @Test und assertEquals.

Problem

Der Rabatt, der leise kaputtging

Die Regel des Shops lautet: Der Rabatt ist in Prozent, und bei einem Einkauf ab 100 € kommen noch 5% dazu. Du schreibst die Methode und probierst sie von Hand aus:

Main.java
class Discount {
// Rabatt in Prozent. Ab 100 € kommen noch 5% dazu.
static double apply(double price, int percent) {
if (price > 100) {
percent = percent + 5;
}
return price * (100 - percent) / 100;
}
}
System.out.println(Discount.apply(50, 10));
System.out.println(Discount.apply(200, 10));
Ausgabe
45.0
170.0

50 € mit 10% Rabatt sind 45 €, und 200 € mit 15% sind 170 €. Das sieht richtig aus, also gibst du es frei. Eine Woche später zahlt eine Kundin mit einem Einkauf von genau 100 € 90 € statt 85 €. Die Bedingung ist price > 100, sie müsste price >= 100 sein.

Niemand hat genau 100 € ausprobiert. Und bei jeder weiteren Änderung an der Methode musst du alle Fälle wieder von Hand ausprobieren.

Die Idee

Code, der Code prüft

Statt die Ausgabe mit eigenen Augen zu lesen, schreibst du kleine Methoden, die deinen Code aufrufen und das Ergebnis mit dem erwarteten vergleichen. Das ist ein Test. Du führst alle Tests mit einem Klick aus, nach jeder Änderung, und sie sagen dir, was kaputtgegangen ist.

Art Was sie prüft Beispiel
Unit-Test ein kleines Stück, meist eine Methode, getrennt vom Rest Discount.apply(100, 10) gibt 85 zurück
Integrationstest wie die Stücke zusammenarbeiten: mit einer Datenbank, einer Datei, einem anderen Dienst die Bestellung wird in der Datenbank gespeichert und wieder gelesen
End-to-End-Test die ganze Anwendung, so wie ein Mensch sie benutzt die Kundin legt etwas in den Warenkorb, bezahlt und bekommt eine E-Mail
In dieser Lektion schreiben wir Unit-Tests. In Java nimmst du dafür die Bibliothek JUnit.

Warum es sich lohnt:

  • Du siehst den Fehler sofort, solange du noch weißt, was du geändert hast, und nicht erst, wenn sich die Kundin beschwert;
  • der Test zeigt, wie der Code benutzt wird: welcher Fall was zurückgibt;
  • du kannst den Code mutig aufräumen. Wenn die Tests nach der Änderung immer noch grün sind, hat sich das Verhalten nicht geändert.
Genauer, für Neugierige

Es gibt auch andere Arten von Tests: funktionale (erfüllt das Programm die Anforderungen), Performancetests (wie viele Anfragen es aushält), Sicherheitstests (gibt es Lücken) und Tests der Benutzeroberfläche (die Buttons, die Formulare). Alle bauen auf Unit-Tests auf: Von ihnen gibt es die meisten, und sie sind am schnellsten und am billigsten zu pflegen.

Sieh es dir an

Der erste Test

Die Tests für Discount stehen in einer eigenen Klasse, DiscountTest:
DiscountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@Test
void tenPercentOff() {
assertEquals(45.0, Discount.apply(50, 10));
}
@Test
void extraFivePercentFrom100() {
assertEquals(85.0, Discount.apply(100, 10));
}
}
  • @Test vor der Methode sagt JUnit „das ist ein Test, führe ihn aus“. Solche Etiketten mit @ heißen Annotationen. @Override hast du schon gesehen. Wie JUnit sie findet, siehst du in der letzten Lektion des Moduls.
  • assertEquals(erwartet, tatsächlich) vergleicht die beiden Werte. Wenn sie verschieden sind, schlägt der Test fehl.

Wir führen die beiden Tests gegen den Code aus der ersten Karte aus:

Ergebnis
FEHLER extraFivePercentFrom100(): expected: <85.0> but was: <90.0>
OK tenPercentOff()
Tests: 2, bestanden: 1, fehlgeschlagen: 1

Der erste Test besteht. Der zweite schlägt fehl und sagt genau, was passiert ist: Er hat 85.0 erwartet, und die Methode hat 90.0 zurückgegeben. Du änderst die Bedingung in price >= 100, führst sie noch einmal aus, und beide sind grün:

Ergebnis
OK extraFivePercentFrom100()
OK tenPercentOff()
Tests: 2, bestanden: 2, fehlgeschlagen: 0

Jeder Test macht drei Dinge: Er bereitet die Werte vor, ruft den Code auf und prüft das Ergebnis.

class DiscountTest {
@Test
void tenPercentOff() {
assertEquals(45.0, Discount.apply(50, 10));
}
@Test
void extraFivePercentFrom100() {
assertEquals(85.0, Discount.apply(100, 10));
}
}
Schritt für Schritt, in Worten
  1. DiscountTest hat zwei Tests. JUnit führt jeden einzeln aus und schaltet ein Lämpchen daneben ein.
  2. Test 1, vorbereiten: ein Preis von 50 € und 10% Rabatt.
  3. Ausführen: Discount.apply(50, 10) gibt 45.0 zurück.
  4. Prüfen: assertEquals vergleicht den erwarteten Wert 45.0 mit dem zurückgegebenen 45.0. Sie sind gleich, also besteht der Test.
  5. Test 2: genau 100 € und 10%. Wir erwarten 85.0, weil es ab 100 € noch 5% gibt.
  6. Die Bedingung in der Methode ist price > 100, und 100 ist nicht größer als 100. Die Methode gibt 90.0 zurück.
  7. Erwartet 85.0, zurückgegeben 90.0: Der Test schlägt fehl und sagt genau das.
  8. Du änderst die Bedingung in price >= 100 und führst die Tests noch einmal aus.
  9. Jetzt erfüllt 100 die Bedingung, und die Methode gibt 85.0 zurück.
  10. Erwartet und zurückgegeben stimmen überein. Beide Tests sind grün.
Genauer, für Neugierige
  • In einem echten Projekt stehen die Tests in src/test/java, neben dem Code in src/main/java. IntelliJ zeigt neben jedem Test und neben der Klasse einen grünen Pfeil: Du klickst darauf und siehst das Ergebnis.
Struktur
src/main/java/Discount.java der Code
src/test/java/DiscountTest.java die Tests dazu
  • JUnit fügst du als Abhängigkeit hinzu. Mit Maven steht das in pom.xml, und die Tests führst du mit mvn test aus:
pom.xml
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.4</version>
<scope>test</scope>
</dependency>
  • Hier auf der Seite werden die Tests aus main über die Programmierschnittstelle von JUnit (die Launcher API) ausgeführt. Die Methode unten führt die Klassen aus und gibt die Ergebnisse nach Namen sortiert aus. Sie sind sortiert, weil JUnit keine Reihenfolge der Tests verspricht:
Der Test-Runner
static void runTests(Class<?>... classes) {
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(Arrays.stream(classes).map(DiscoverySelectors::selectClass).toList())
.build();
List<String> lines = new ArrayList<>();
int[] failed = { 0 };
TestExecutionListener listener = new TestExecutionListener() {
@Override
public void executionFinished(TestIdentifier id, TestExecutionResult result) {
if (!id.isTest()) return;
if (result.getStatus() == TestExecutionResult.Status.SUCCESSFUL) {
lines.add("OK " + id.getDisplayName());
} else {
failed[0]++;
lines.add("FEHLER " + id.getDisplayName() + ": " + result.getThrowable().map(Throwable::getMessage).orElse(""));
}
}
};
LauncherFactory.create().execute(request, listener);
Collections.sort(lines);
lines.forEach(System.out::println);
System.out.println("Tests: " + lines.size() + ", bestanden: " + (lines.size() - failed[0]) + ", fehlgeschlagen: " + failed[0]);
}

Probier es aus

Mehrwertsteuer mit double

Der Preis mit Mehrwertsteuer ist der Preis mal 1,2. Zwei Tests:

Was zeigen die Tests?

class Vat {
static double withVat(double price) {
return price * 1.2;
}
}
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class VatTest {
@Test
void coffee() {
assertEquals(12.0, Vat.withVat(10));
}
@Test
void tea() {
assertEquals(3.6, Vat.withVat(3));
}
}
Antwort

Die Idee

Ja oder nein, und eine erwartete Exception

Neben Gleichheit prüfst du oft eine Bedingung oder dass der Code ablehnt. Hier ist Account aus dem Modul über Exceptions, mit einer neuen Methode canPay:

Account.java
class InsufficientFundsException extends RuntimeException {
public InsufficientFundsException(int shortfall) {
super("Es fehlen " + shortfall + " €");
}
}
class Account {
private int balance;
public Account(int balance) {
this.balance = balance;
}
public int getBalance() {
return balance;
}
public boolean canPay(int amount) {
return amount <= balance;
}
public void withdraw(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Der Betrag muss positiv sein");
}
if (amount > balance) {
throw new InsufficientFundsException(amount - balance);
}
balance -= amount;
}
}
AccountTest.java
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
@Test
void canPayWhatIsThere() {
Account account = new Account(100);
assertTrue(account.canPay(100));
assertFalse(account.canPay(101));
}
@Test
void withdrawTooMuch() {
Account account = new Account(100);
InsufficientFundsException e = assertThrows(InsufficientFundsException.class,
() -> account.withdraw(130));
assertEquals("Es fehlen 30 €", e.getMessage());
assertEquals(100, account.getBalance());
}
}
Ausgabe
OK canPayWhatIsThere()
OK withdrawTooMuch()
Tests: 2, bestanden: 2, fehlgeschlagen: 0
  • assertTrue und assertFalse prüfen eine Bedingung.
  • assertThrows(Art.class, Code) schlägt fehl, wenn der Code keine Exception dieser Art wirft. Den Code übergibst du als Lambda: assertThrows führt ihn selbst aus und fängt die Exception. Dann gibt es sie zurück, damit du auch die Meldung prüfen kannst.

Der zweite Test prüft auch etwas, das man leicht vergisst: Nach der Ablehnung ist der Kontostand derselbe.

Die Idee

Jeder Test beginnt sauber

Hier teilen sich die beiden Tests ein Konto mit 100 €:

AccountTest.java
class AccountTest {
private static Account account = new Account(100);
@Test
@DisplayName("40 € abheben lässt 60 € übrig")
void withdraw40() {
account.withdraw(40);
assertEquals(60, account.getBalance());
}
@Test
@DisplayName("70 € abheben lässt 30 € übrig")
void withdraw70() {
account.withdraw(70);
assertEquals(30, account.getBalance());
}
}
Ausgabe
FEHLER 70 € abheben lässt 30 € übrig: Es fehlen 10 €
OK 40 € abheben lässt 60 € übrig
Tests: 2, bestanden: 1, fehlgeschlagen: 1

JUnit hat zuerst den Test für 40 € ausgeführt. Beim zweiten sind noch 60 € übrig, und er hebt 70 € ab. Wäre die Reihenfolge umgekehrt, würde der andere fehlschlagen. Ein Test, der von einem anderen Test abhängt, ist eine Falle. Deshalb wird das Objekt vor jedem Test mit @BeforeEach neu erstellt:

AccountTest.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class AccountTest {
private Account account;
@BeforeEach
void newAccount() {
account = new Account(100);
}
@Test
@DisplayName("40 € abheben lässt 60 € übrig")
void withdraw40() {
account.withdraw(40);
assertEquals(60, account.getBalance());
}
@Test
@DisplayName("70 € abheben lässt 30 € übrig")
void withdraw70() {
account.withdraw(70);
assertEquals(30, account.getBalance());
}
}
Ausgabe
OK 40 € abheben lässt 60 € übrig
OK 70 € abheben lässt 30 € übrig
Tests: 2, bestanden: 2, fehlgeschlagen: 0
@DisplayName gibt dem Test einen Namen in Worten. Er wird im Ergebnis statt des Methodennamens angezeigt.
Genauer, für Neugierige
Weitere Annotationen, denen du begegnen wirst:
Annotationen
@AfterEach nach jedem Test (Aufräumen)
@BeforeAll einmal vor allen Tests der Klasse (static-Methode)
@AfterAll einmal nach allen (static-Methode)
@Disabled("...") der Test wird vorerst übersprungen
@Timeout(5) der Test schlägt fehl, wenn er länger als 5 Sekunden dauert
@Nested eine verschachtelte Klasse mit einer Gruppe zusammengehöriger Tests
@Tag("fast") ein Etikett, damit du nur einen Teil der Tests ausführst
@RepeatedTest(5) derselbe Test 5-mal
@ValueSource(ints = { 1, 2, 3 }) ein Wert pro Durchlauf eines parametrisierten Tests
@CsvFileSource(resources = "/data.csv") die Zeilen kommen aus einer Datei
@MethodSource("prices") die Argumente kommen aus einer static-Methode
@TempDir Path dir ein temporärer Ordner, der nach dem Test gelöscht wird
@ExtendWith(...), @RegisterExtension Erweiterungen von JUnit
@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class)
die Namen werden aus den Methodennamen gebildet

Die Idee

Eine Prüfung, viele Fälle

Vier Fälle für den Rabatt bedeuten vier fast gleiche Methoden. @ParameterizedTest führt eine Methode mit verschiedenen Werten aus, und @CsvSource liefert sie als Zeilen „Preis, Prozent, erwartet“:

DiscountTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class DiscountTest {
@ParameterizedTest(name = "{0} € mit {1}% = {2} €")
@CsvSource({
"50, 10, 45.0",
"99, 0, 99.0",
"100, 10, 85.0",
"200, 20, 150.0"
})
void apply(double price, int percent, double expected) {
assertEquals(expected, Discount.apply(price, percent));
}
}
Ausgabe
OK 100 € mit 10% = 85.0 €
OK 200 € mit 20% = 150.0 €
OK 50 € mit 10% = 45.0 €
OK 99 € mit 0% = 99.0 €
Tests: 4, bestanden: 4, fehlgeschlagen: 0

Jede Zeile ist ein eigener Test mit einem eigenen Ergebnis. name in @ParameterizedTest bildet seinen Namen aus den Werten: {0} ist der erste, {1} der zweite. Ein neuer Fall ist eine neue Zeile, keine neue Methode. Die wichtigen Fälle liegen an den Grenzen: genau 100 €, 99 €, 0%.

Probier es aus

Finde den Fehler anhand des Tests

Die Lieferung kostet 4 €, ab 50 € ist sie kostenlos. Der Test:

DeliveryTest.java
class DeliveryTest {
@ParameterizedTest(name = "Bestellung über {0} €: Lieferung {1} €")
@CsvSource({ "20, 4", "50, 0", "80, 0" })
void price(int total, int expected) {
assertEquals(expected, Delivery.price(total));
}
}
Ausgabe
FEHLER Bestellung über 50 €: Lieferung 0 €: expected: <0> but was: <4>
OK Bestellung über 20 €: Lieferung 4 €
OK Bestellung über 80 €: Lieferung 0 €
Tests: 3, bestanden: 2, fehlgeschlagen: 1

In welcher Zeile des Codes ist der Fehler?

Wähle die Zeile mit dem Fehler.

Die Idee

Zuerst der Test

Die Kasse soll den Gutscheincode HERBST10 für 10% Rabatt annehmen, egal ob er klein oder groß geschrieben ist. Diesmal schreibst du zuerst die Tests:

PromoTest.java
class PromoTest {
@Test
void knownCode() {
assertEquals(10, Promo.percent("HERBST10"));
}
@Test
void codeIsCaseInsensitive() {
assertEquals(10, Promo.percent("herbst10"));
}
@Test
void unknownCode() {
assertEquals(0, Promo.percent("KEINER"));
}
}
  1. Rot. Vorerst gibt die Methode 0 zurück. Die Tests schlagen fehl, und das ist gut: Es bedeutet, dass sie wirklich etwas prüfen.
class Promo {
static int percent(String code) {
return 0;
}
}
Ausgabe
FEHLER codeIsCaseInsensitive(): expected: <10> but was: <0>
FEHLER knownCode(): expected: <10> but was: <0>
OK unknownCode()
Tests: 3, bestanden: 1, fehlgeschlagen: 2
  1. Grün. Du schreibst den einfachsten Code, mit dem sie bestehen:
class Promo {
static int percent(String code) {
if (code.equalsIgnoreCase("HERBST10")) {
return 10;
}
return 0;
}
}
Ausgabe
OK codeIsCaseInsensitive()
OK knownCode()
OK unknownCode()
Tests: 3, bestanden: 3, fehlgeschlagen: 0
  1. Aufräumen. Jetzt kannst du die Codes in eine HashMap verschieben, neue hinzufügen und die Methode aufräumen. Die Tests sagen dir, wenn du etwas kaputt machst.

Das ist TDD (test-driven development, testgetriebene Entwicklung). Wenn du den Test zuerst schreibst, musst du entscheiden, was genau die Methode tun soll, bevor du darüber nachdenkst, wie.

Genauer

Suites und Ersatzobjekte

Eine Suite ist eine Gruppe von Testklassen, die zusammen ausgeführt werden, zum Beispiel alle Tests der Kasse:

CheckoutSuite.java
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;
import org.junit.platform.suite.api.SuiteDisplayName;
@Suite
@SuiteDisplayName("Tests der Kasse")
@SelectClasses({ DiscountTest.class, AccountTest.class, DeliveryTest.class })
class CheckoutSuite {
}

Wenn du sie ausführst, führt sie die Tests der drei Klassen aus.

Genauer, für Neugierige
  • Manchmal hängt der Code von etwas ab, das du in einem Test nicht aufrufen willst: ein Wechselkurs von einer Bank, eine Zahlung, eine E-Mail. Dann übergibst du ein Ersatzobjekt (Mock), das zurückgibt, was du ihm sagst. Die bekannteste Bibliothek dafür ist Mockito:
PriceInUsdTest.java
import static org.mockito.Mockito.*;
interface ExchangeRates {
double eurTo(String currency);
}
class PriceInUsdTest {
@Test
void convertsWithTodaysRate() {
ExchangeRates rates = mock(ExchangeRates.class);
when(rates.eurTo("USD")).thenReturn(1.10);
Converter converter = new Converter(rates);
assertEquals(11.0, converter.toUsd(10), 0.001);
verify(rates).eurTo("USD");
}
}

mock erstellt ein Objekt, das das Interface implementiert, when(...).thenReturn(...) sagt, was es zurückgeben soll, und verify prüft, dass die Methode aufgerufen wurde. Hier auf der Seite wird Mockito nur kompiliert: Um zu laufen, braucht es noch eine Bibliothek (byte-buddy).

  • Weitere Assertions neben assertEquals, assertTrue und assertThrows:
Assertions
assertNotEquals(a, b) sind sie verschieden
assertSame(a, b) ist es dasselbe Objekt (==), nicht nur gleich
assertNotSame(a, b)
assertNull(x), assertNotNull(x)
assertArrayEquals(a, b) die Arrays haben dieselben Elemente
assertIterableEquals(a, b) zwei Listen mit denselben Elementen in derselben Reihenfolge
assertLinesMatch(a, b) Textzeilen (reguläre Ausdrücke sind auch erlaubt)
assertAll(() -> ..., () -> ...) führt alle Prüfungen aus und meldet alle Fehler
assertTimeout(Duration.ofMillis(100), () -> ...)
fail("Meldung") der Test schlägt sofort fehl

Übung

Schreib die Tests

Löse die Aufgaben in deinem Editor, mit JUnit 5 im Projekt.

1. Der Rabattrechner. Schreib DiscountTest für Discount.apply aus der ersten Karte. Deck diese Fälle ab: 80 € mit 10% Rabatt sind 72 €, 100 € mit 0% sind 95 €, 150 € mit 20% sind 112,50 €, 0 € mit 30% sind 0 €, und genau 100 € mit 10% sind 85 €. Führ sie auch gegen die kaputte Version mit price > 100 aus. Genau die beiden Fälle mit 100 € sollen fehlschlagen:

Ergebnis gegen die kaputte Version
FEHLER 100 € mit 0% = 95.0 €: expected: <95.0> but was: <100.0>
FEHLER exactly100GetsTheExtra(): expected: <85.0> but was: <90.0>
OK 0 € mit 30% = 0.0 €
OK 150 € mit 20% = 112.5 €
OK 80 € mit 10% = 72.0 €
Tests: 5, bestanden: 3, fehlgeschlagen: 2

2. Das Konto. Schreib AccountTest für Account aus der Karte „Ja oder nein, und eine erwartete Exception“. Erstelle das Konto mit 100 € in @BeforeEach. Prüfe, dass:

  • 30 € abheben 70 € übrig lässt;
  • du alles abheben kannst und 0 übrig bleibt;
  • 150 € abheben eine InsufficientFundsException mit der Meldung „Es fehlen 50 €“ wirft und der Kontostand 100 € bleibt;
  • 0 und -20 € eine IllegalArgumentException werfen (ein parametrisierter Test mit @ValueSource(ints = { 0, -20 })).

3. Der Fehler im Notendurchschnitt. Die Methode sieht richtig aus:

Grades.java
class Grades {
static double average(int[] grades) {
int sum = 0;
for (int g : grades) {
sum += g;
}
return sum / grades.length;
}
}
Schreib zwei Tests: Der Durchschnitt von 4 und 6 ist 5,0, und der Durchschnitt von 5 und 6 ist 5,5 (mit einer erlaubten Abweichung von 0,001). Einer davon schlägt fehl:
Ergebnis
FEHLER averageWithAFraction(): expected: <5.5> but was: <5.0>
OK wholeAverage()
Tests: 2, bestanden: 1, fehlgeschlagen: 1

Finde die Ursache und korrigiere die Methode, damit beide bestehen.

Test

Teste dich

1. Was prüft ein Unit-Test?
2. Der Test schlägt fehl mit „expected: <85.0> but was: <90.0>“. Was hat die Methode zurückgegeben?
3. Warum wird das Konto in @BeforeEach erstellt und nicht einmal in einem Feld?
4. Wie prüfst du, dass withdraw(130) eine InsufficientFundsException wirft?
5. Was ist die Reihenfolge bei TDD?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·