Behavior-Driven Development mit JUnit 5

Keine Kommentare

Behavior-Driven Development (BDD) ist ein Ansatz in der Softwareentwicklung, der den Fokus auf das Verhalten eines Systems setzt. Dies wird durch eine entsprechende Strukturierung von Testfällen unterstützt. Häufig kommt dabei der Dreiklang aus Given-When-Then zum Einsatz. Wie mein Kollege Tobias Göschel gezeigt hat, ließ sich diese Struktur bereits mit JUnit 4 verwenden. In diesem Blopost werde ich zeigen, wie BDD mithilfe der @Nested-Annotation in JUnit 5 umgesetzt werden kann. Wer noch nicht mit @Nested gearbeitet hat, dem sei mein Einführungsblogpost zu diesem Thema ans Herz gelegt.

Vorüberlegung

Als Beispiel für diesen Blogpost möchte ich die Stack-Klasse aus der Java-Standardbibliothek verwenden. Stellen wir uns den einfachsten Test für diese Klasse vor: Wenn ein Stack leer ist, dann sollte seine Größe 0 sein. Mit JUnit könnte man das so testen:

@Test
void emptyStackShouldHaveSizeZero() {
    // given
    stack.clear();
 
    // when
    int size = stack.size();
 
    // then
    assertEquals(0, size);
}

In klassischen, „flachen“ JUnit-Tests findet man häuft diese implizite Given-When-Then-Struktur. Besonders in objektorientierten Systemen ergibt sich das ganz natürlich aus der Tatsache, dass die zu testenden Objekte zunächst in den richtigen Zustand versetzt werden müssen (Given), danach wird eine Aktion ausgeführt (When) und schließlich wird das Ergebnis dieser Aktion überprüft (Then).

Wenn wir mehrere Tests mit einem leeren Stack machen wollen, müssen wir jeweils das Given in den Testmethoden wiederholen. Alternativ können wir das Herstellen eines leeren Stacks in eine Setup-Methode auslagern. Dieser Ansatz funktioniert allerdings nur so lange, bis wir den ersten Test mit einem gefüllten Stack schreiben wollen. Von da an müssen wir für alle Tests mit gefülltem Stack zunächst ein Element hinzufügen. Diese Wiederholung können wir wiederum beheben, indem wir eine weitere Stack-Instanz anlegen und in der Setup-Methode befüllen.

Für den Test eines Stacks mag dies noch gut handhabbar sein. In Geschäftsanwendungen arbeiten wir aber in der Regel mit deutlich komplexeren Objekten und müssen deren Interaktionen mit anderen Objekten beachten. Hier sieht man dann häufig riesige Setup-Blöcke, in denen mehrere Objektinstanzen und Mocks erzeugt und konfiguriert werden, die dann jeweils nur von einigen Testmethoden verwendet werden. Möchte man einen Test hinzufügen, führen schon minimale Änderungen der Konfiguration dazu, dass andere Tests fehlschlagen. Die Folge: Test Setups werden kopiert, die Tests werden unübersichtlicher, die Wartbarkeit leidet.

Bessere Test durch geschachtelte Kontexte

Hier zeigt sich ein zentrales Problem einer flachen Teststruktur: Obwohl unterschiedliche Setups aufeinander aufbauen, lässt sich dieser Fakt nicht in der Testimplementierung abbilden. Im Beispiel des Stacks können wir uns den gefüllten Stack als Ableitung des leeren Stacks vorstellen. Wir bekommen einen gefüllten Stack, indem wir dem leeren Stack mindestens ein Element hinzufügen. Mit JUnit 5 können wir genau das durch geschachtelte Testklassen erreichen:

class StackTests {
 
    private Stack<String> stack;
 
    @BeforeEach
    void setUp() {
        stack = new Stack<>();
    }
 
    @Nested
    class GivenAnEmptyStack {
 
        @BeforeEach
        void setUp() {
            stack.clear();
        }
 
        @Test
        void thenTheSizeOfTheStackShouldBeZero() {
            assertEquals(0, stack.size());
        }
 
        // more tests for verifying empty stack behavior
 
        @Nested
        class WhenAnElementIsAdded {
 
            @BeforeEach
            void setUp() {
                stack.add("elem");
            }
 
            @Test
            void thenTheSizeOfTheStackShouldBeOne() {
                assertEquals(1, stack.size());
            }
 
            // more test for verifying filled stack behavior
        }
    }
}

Wie zu sehen ist, benötigen wir nur eine Stack-Instanz im Test, können diese aber für die verschiedenen Fälle unterschiedlich konfigurieren. Dies gelingt dadurch, dass jede geschachtelte Testklasse einen eigenen Subkontext aufbaut: Das Hinzufügen des Elements "elem" ist nur für die Testmethoden innerhalb der Klasse WhenAnElementIsAdded sichtbar. Diese Struktur führt auch dazu, dass die Testmethoden jeweils nur noch die Assertion enthalten – es ist offensichtlicher, was hier eigentlich geprüft wird. Der Aufbau des benötigten Zustands verschiebt sich in die Setup-Methoden und kann so zwischen den Testmethoden geteilt werden.

Darüber hinaus wird diese Struktur von IDEs wie IntelliJ sehr übersichtlich dargestellt:

Darstellung der Given-When-Then Teststruktur in IntelliJ

Zu Verbesserung der Lesbarkeit habe ich hier noch mit mit @DisplayName gearbeitet. Das vollständige Codebeispiel findet ihr auf GitHub.

Fazit

Klassische JUnit-Tests mit „flacher“ Teststruktur werden häufig durch sich wiederholende Setups unübersichtlich. In diesem Blogpost habe ich gezeigt, wie sich dies durch den Given-When-Then-Ansatz vermeiden lässt. Dazu habe ich die @Nested-Annotation aus JUnit 5 verwendet, um die unterschiedlichen Testkontexte ineinander zu schachteln und so die Wiederverwendung zu erhöhen.

Benedikt Ritter arbeitet seit September 2013 als Software Crafter bei der codecentric AG. Sein Können bringt er nicht nur in der Berufswelt zum Einsatz: Benedikt ist Member der Apache Software Foundation und Committer beim Apache Commons Projekt.

Kommentieren

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.