Wie Parametrisierung Unit Tests übersichtlicher macht

  • von Autor

Thomas Kloppe, Softwareentwickler bei sidion

Schnellere Auslieferung, bessere Tests

Ein Commit, ein Pull-Request Review, ein Merge und schon ist der Code auf dem Weg in die Produktivumgebung - zumindest im Falle von Continuos Deployment. Aber auch, wenn Teile des Auslieferungsprozesses manuell funktionieren, sollte man seinen Tests vertrauen können. Das heißt, man muss die Testszenarien verstehen, den Testcode strukturiert und übersichtlich halten. Ich möchte hier ein Mittel zur Strukturierung vorstellen, dass ich in letzter Zeit sehr gerne und häufig eingesetzt habe.

Was sind parametrisierte Tests?

Parametrisierte Tests sind ein Feature von JUnit5, genauer gesagt, ein JUnit Jupiter Feature. Es ermöglicht, einen Test mit unterschiedlichen Parametern mehrfach auszuführen.

Um parametrisierte Tests zu verwenden, muss die entsprechende Dependency dem Projekt hinzugefügt werden:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.9.2</version>
    <scope>test</scope>
</dependency>

Im Java Code sind nur ein paar wenige Anpassungen nötig.

Sieht der JUnit5 Test vorher so aus,

@Test
void testMyFunction() {
 ...
}

wird die @Test Annotation durch @ParameterizedTest ersetzt. Außerdem werden ein oder mehrere Parameter übergeben. Durch eine zweite Annotation wird definiert, wo diese herkommen.

@ParameterizedTest
@ValueSource(ints = {1, 2, 3})
void testMyFunction(int number) {
 ...
}

Es gibt von @ValueSource über @MethodSource bis hin zu @CsvSource eine Reihe Möglichkeiten, die Parameter einzulesen. Zu den Details und der vollständigen Dokumentation verweise ich auf die am Ende des Artikels verlinkten Quellen.

Testfälle zu einem Beispiel

Nehmen wir an, der Benutzer einer Anwendung wählt ein neues Passwort. Nun prüft die Anwendung, ob dieses Passwort den nötigen Anforderungen entspricht.

Hier die Methode, die die Prüfung implementiert.

    public Optional<PasswordError> validatePassword(final User user) {
        final String password = user.getPassword();
        if(password == null || password.trim().isEmpty()) {
            return Optional.of(PasswordError.NULL_OR_EMPTY);
        }
        return switch (password) {
            case String p && p.length() < 8 -> Optional.of(PasswordError.TO_SHORT);
            case String p && (p.toLowerCase().equals(p) || p.toUpperCase().equals(p)) -> Optional.of(PasswordError.BIG_AND_SMALL_CHARS);
            case String p && p.matches("^[A-Za-z0-9]*$") -> Optional.of(PasswordError.SPECIAL_CHARACTERS);
            default -> Optional.empty();
        };
    }

Die Methode gibt für die folgenden Bedingungen verschiedene Meldungen zurück:

  1. Ist das Passwort leer?
  2. Ist es lang genug?
  3. Werden Groß- und Kleinbuchstaben verwendet?
  4. Kommen Sonderzeichen vor?

Ein JUnit Test sähe zum Beispiel so aus:

    @Test
    void testValidatePassword() {
        final User user = User.builder().password("HaLloW3l!").build();

        Optional<PasswordService.PasswordError> passwordError = passwordService.validatePassword(user);

        assertTrue(passwordError.isEmpty());
    }

Zugegeben, der Test ist sehr kompakt. Dies liegt aber auch daran, dass das Beispiel einfach gehalten ist. In der Praxis werden meist Mocks für das zu testende Objekt benötigt. Auch sind die Objekte, die an die Methode übergeben werden, oft komplexer. Aus 3 Zeilen können dann ganz schnell 10 werden. Bei 5 Testfällen wären das schon 50 Zeilen Code.

Wir brauchen viele Testfälle

Machen wir uns auf die Suche nach weiteren Testfällen, indem wir Äquivalenzklassen bilden. Bei dieser Methode werden die Eingaben hinsichtlich der Ausgaben, die sie erzeugen, gruppiert. Zu jeder Äquivalenzklasse, also jeder möglichen Ausgabe, sollte mindestens ein Test existieren.

In unserem Beispiel wird also für jeden möglichen Fehlertyp ein weiterer Testfall benötigt. Aber reicht das? Ein weiterer Testfall sollte sicherstellen, dass sich NULL genauso verhält wie ein leerer String. Nehmen wir an, dass die Reihenfolge der Prüfungen ebenfalls eine Anforderung der Anwendung ist, dann sollten wir auch hierfür Tests schreiben. Im nächsten Abschnitt habe ich 9 Testfälle für die Methode definiert. Vermutlich lassen sich noch mehr sinnvolle Tests finden.

Implementierung mit parametrisierten Tests

Wie sehen die Testfälle aus, wenn wir die @MethodSource Annotation verwenden, und die Testfälle aus einer statischen Methode in der gleichen Klasse einlesen?

    @ParameterizedTest(name = "{index} : \"{0}\" --> {1}")
    @MethodSource("passwordsAndResults")
    void testValidatePassword(final String password, final PasswordService.PasswordError expectedResult) {
        final User user = User.builder().password(password).build();

        Optional<PasswordService.PasswordError> passwordError = passwordService.validatePassword(user);

        if(expectedResult == null) {
            assertTrue(passwordError.isEmpty());
        } else {
            assertEquals(expectedResult, passwordError.get());
        }
    }

    static Stream<Arguments> passwordsAndResults() {
        return Stream.of(Arguments.of("HaLloW3l!", null),
                Arguments.of("HaLloW3lt", PasswordService.PasswordError.SPECIAL_CHARACTERS),
                Arguments.of("HalloWelt", PasswordService.PasswordError.SPECIAL_CHARACTERS),
                Arguments.of("hallowelt", PasswordService.PasswordError.BIG_AND_SMALL_CHARS),
                Arguments.of("HALLOW3L!", PasswordService.PasswordError.BIG_AND_SMALL_CHARS),
                Arguments.of("hallow3l!", PasswordService.PasswordError.BIG_AND_SMALL_CHARS),
                Arguments.of("oW3!", PasswordService.PasswordError.TO_SHORT),
                Arguments.of("hallo", PasswordService.PasswordError.TO_SHORT),
                Arguments.of("", PasswordService.PasswordError.NULL_OR_EMPTY),
                Arguments.of(null, PasswordService.PasswordError.NULL_OR_EMPTY));
    }

In der @MethodSource wird der Name der erzeugenden Methode angegeben. Diese muss einen Stream zurückgeben. Der Typ Arguments gehört zu JUnit Jupiter und kapselt eine Liste an Objekten, die auf Methodenparameter gemapped werden.

Das erste Argument bestimmt die Eingabe in validatePassword(), das zweite ist das erwartete Ergebnis. Damit stehen 9 Testfälle in 9 Zeilen Code direkt unter einander. Ein weiterer könnte mit einer einzigen Zeile hinzugefügt werden.

Auch noch gut zu wissen: Die Namen der Tests können verständlich gestaltet werden. Das ist wichtig, um schnell zu erkennen, wo genau etwas schief geht. Mit dem name Attribut der @ParameterizedTest Annotation wird der Name übergeben. Über Platzhalter im Stil {0}, {1} etc. können die Werte der Methodenparameter eingefügt werden.

Die Zeile

    @ParameterizedTest(name = "{index} : \"{0}\" --> {1}")

erzeugt diese Ausgabe:



Fazit

Ob sich ein parametrisierter Test lohnt, hängt davon ab, wie lang der Code für die einzelnen Testfälle ist und wie viele Testfälle benötigt werden. Es hängt aber auch davon ab, wie viele Parameter benötigt werden. Wird die Signatur der Testmethode unverständlich, kann die kompaktere Schreibweise mehr verwirren als nutzen.

Richtig eingesetzt sind parametrisierte Tests ein wertvolles Werkzeug, das Komplexität reduziert und Zeit spart.

Links

[1] https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests - JUnit5 Dokumentation

[2] https://www.baeldung.com/parameterized-tests-junit-5 - ein komplettes Tutorial

Zurück