Testparameterisierung mit JUnit 5.7: Eine tiefgehende Untersuchung von @EnumSource

Parameterisierte Tests erlauben es Entwicklern, ihren Code effizient mit einer Reihe von Eingangsdaten zu testen. Im Bereich der JUnit-Tests haben erfahrene Benutzer lange Zeit mit den Komplexitäten der Implementierung dieser Tests zu kämpfen gehabt. Mit der Veröffentlichung von JUnit 5.7 beginnt jedoch eine neue Ära der Testparameterisierung, die Entwicklern erstklassige Unterstützung und erweiterte Fähigkeiten bietet. Lassen Sie uns einen Blick auf die aufregenden Möglichkeiten werfen, die JUnit 5.7 für die parameterisierte Testung mitbringt!

Parameterisierung Beispiele aus den JUnit 5.7 Dokumentationen

Sehen wir uns einige Beispiele aus den Dokumentationen an:

Java

 

@ParameterizedTest
@ValueSource(strings = { "racecar", "radar", "able was I ere I saw elba" })
void palindromes(String candidate) {
    assertTrue(StringUtils.isPalindrome(candidate));
}

@ParameterizedTest
@CsvSource({
    "apple,         1",
    "banana,        2",
    "'lemon, lime', 0xF1",
    "strawberry,    700_000"
})
void testWithCsvSource(String fruit, int rank) {
    assertNotNull(fruit);
    assertNotEquals(0, rank);
}

@ParameterizedTest
@MethodSource("stringIntAndListProvider")
void testWithMultiArgMethodSource(String str, int num, List<String> list) {
    assertEquals(5, str.length());
    assertTrue(num >=1 && num <=2);
    assertEquals(2, list.size());
}

static Stream<Arguments> stringIntAndListProvider() {
    return Stream.of(
        arguments("apple", 1, Arrays.asList("a", "b")),
        arguments("lemon", 2, Arrays.asList("x", "y"))
    );
}

Die Annotation @ParameterizedTest muss von einer der mehreren bereitgestellten Quelle.Annotations begleitet werden, die beschreiben, woher die Parameter stammen sollen. Die Quelle der Parameter wird oft als „Datenlieferant“ bezeichnet.

I will not dive into their detailed description here: the JUnit user guide does it better than I could, but allow me to share several observations:

  • Die @ValueSource ist begrenzt auf die Bereitstellung eines einzigen Parameterwerts. Mit anderen Worten, die Testmethode kann nicht mehr als ein Argument haben, und die verwendbaren Typen sind ebenfalls eingeschränkt.
  • Mehrfache Argumente werden etwas durch @CsvSource adressiert, indem jeder String in einen Datensatz zerlegt wird, der dann feldweise als Argumente übergeben wird. Dies kann bei langen Strings und/oder vielen Argumenten schnell schwer lesbar werden. Auch hier sind die verwendbaren Typen eingeschränkt — mehr dazu später.
  • Alle Quellen, die tatsächliche Werte in Annotationen angeben, sind beschränkt auf Werte, die Konstanten zur Kompilierungszeit sind (Einschränkung von Java Annotations, nicht JUnit).
  • @MethodSource und @ArgumentsSource stellen einen Datenstrom/Sammlung von (untypisierten) n-Tupeln bereit, die dann als Methodenargumente übergeben werden. Verschiedene tatsächliche Typen werden unterstützt, um die Sequenz von n-Tupeln darzustellen, aber keines von ihnen gewährleistet, dass sie in die Methodenargumentliste passen. Diese Art von Quelle erfordert zusätzliche Methoden oder Klassen, aber sie stellt keine Beschränkung darauf, wo und wie die Testdaten obtained werden.

Wie Sie sehen können, reichen die verfügbaren Quelltypen von den einfachen (einfach zu verwenden, aber funktional begrenzt) bis hin zu den ultimativ flexiblen, die mehr Code zum Laufen bringen erfordern.

  • Sidennote — Dies ist im Allgemeinen ein Zeichen guter Gestaltung: Ein wenig Code ist für die grundlegende Funktionalität erforderlich, und die Hinzufügung zusätzlicher Komplexität ist gerechtfertigt, wenn sie verwendet wird, um einen anspruchsvolleren Use Case zu ermöglichen.

Was nicht zu diesem hypothetischen Kontinuum von einfach bis flexibel zu passen scheint, ist @EnumSource. Sehen Sie sich dieses nicht-triviale Beispiel von vier Parametersätzen mit je 2 Werten an.

  • Nota — Während @EnumSource den Wert des Enums als ein einzelnes Testmethodenargument überträgt, ist konzeptionell die Testmethode durch die Felder des Enums parameterisiert, was keine Beschränkung auf die Anzahl der Parameter darstellt.
Java

 

    enum Direction {
        UP(0, '^'),
        RIGHT(90, '>'),
        DOWN(180, 'v'),
        LEFT(270, '<');

        private final int degrees;
        private final char ch;

        Direction(int degrees, char ch) {
            this.degrees = degrees;
            this.ch = ch;
        }
    }

    @ParameterizedTest
    @EnumSource
    void direction(Direction dir) {
        assertEquals(0, dir.degrees % 90);
        assertFalse(Character.isWhitespace(dir.ch));
        
        int orientation = player.getOrientation();
        player.turn(dir);
        assertEquals((orientation + dir.degrees) % 360, player.getOrientation());
    }

Denken Sie nur mal darüber nach: Die hartcodierte Liste von Werten begrenzt seine Flexibilität erheblich (keine externen oder generierten Daten), während die zusätzliche Menge an Code, die benötigt wird, um das enum zu deklarieren, diese Alternative im Vergleich zu etwas wie @CsvSource recht wortreich macht.

Aber das ist nur ein erster Eindruck. Wir werden sehen, wie elegant das werden kann, wenn man die wahre Kraft der Java-Enums nutzt.

  • Randbemerkung:Dieser Artikel behandelt nicht die Überprüfung von Enums, die Teil Ihres Produktionscodes sind. Diese mussten natürlich unabhängig davon, wie Sie sie überprüfen, deklariert werden. Stattdessen konzentriert er sich darauf, wann und wie man seine Testdaten in Form von Enums ausdrücken kann.

Quando usare

Es gibt Situationen, in denen Enums besser als Alternativen funktionieren:

Mehrfache Parameter pro Test

Wenn alles, was Sie brauchen, ein einzelner Parameter ist, möchten Sie wahrscheinlich nicht über @ValueSource hinaus komplizieren. Aber sobald Sie mehrere Parameter benötigen – zum Beispiel Eingaben und erwartete Ergebnisse – müssen Sie auf @CsvSource, @MethodSource/@ArgumentsSource oder @EnumSource zurückgreifen.

In gewisser Weise lässt ein enum Sie „irgendwie“ eine beliebige Anzahl von Datenfeldern „schmuggeln“.

Wenn Sie also in Zukunft mehr Parameter für Ihre Testmethoden hinzufügen müssen, fügen Sie einfach mehr Felder in Ihre bestehenden Enums hinzu, ohne die Signatur der Testmethoden zu verändern. Dies wird unersetzlich, wenn Sie Ihren Datensatz in mehreren Tests wiederverwenden.

Für andere Quellen muss man ArgumentsAccessors oder ArgumentsAggregators einsetzen, um die Flexibilität zu haben, dieEnums von Haus aus bieten.

Typsicherheit

Für Java-Entwickler sollte das ein großer Vorteil sein.

Parameter, die aus CSV (Dateien oder Literale) gelesen werden, @MethodSource oder @ArgumentsSource, bieten keine Kompilierzeitgarantie, dass die Parameteranzahl und ihre Typen auf die Signatur passen.

Natürlich wird JUnit im Laufzeitfehler reklamieren, aber denken Sie nicht an Unterstützung durch Ihr IDE.

Wie zuvor, das summiert sich, wenn Sie die gleichen Parameter für mehrere Tests wiederverwenden. Ein typsicherer Ansatz wäre ein großer Vorteil, wenn Sie die Parametersätze in Zukunft erweitern.

Benutzerdefinierte Typen

Dies ist hauptsächlich ein Vorteil gegenüber textbasierten Quellen, wie denjenigen, die Daten aus CSV lesen — die in Text codierten Werte müssen in Java-Typen konvertiert werden.

Wenn Sie eine benutzerdefinierte Klasse haben, die aus dem CSV-Datensatz instanziert werden soll, können Sie dies mit ArgumentsAggregator tun. Ihre Daten.deklaration ist jedoch immer noch nicht typsicher — jede Ungereimtheit zwischen der Methodensignatur und den deklarierten Daten wird im Laufzeitfehler beim „Zusammenfassen“ von Argumenten auftreten. Ganz zu schweigen davon, dass die Deklaration der Aggregatorklasse mehr Unterstützungskode hinzufügt, der für Ihre Parametrisierung erforderlich ist. Und wir haben immer @CsvSource gegenüber @EnumSource bevorzugt, um den zusätzlichen Code zu vermeiden.

Dokumentierbar

Im Gegensatz zu den anderen Methoden haben Enum-Quellen Java-Symbole sowohl für die 参数mengen (Enum-Instanzen) als auch für alle Parameter, die sie enthalten (Enum-Felder). Sie bieten einen direkten Ort, an dem Dokumentation in ihrer natürlicheren Form angehängt werden kann – die JavaDoc.

Es ist nicht so, dass Dokumentation nicht an anderer Stelle platziert werden kann, aber sie wird – per Definition – weiter von dem entfernt platziert, was sie dokumentiert, und daher schwerer zu finden und einfacher veraltet zu werden.

Aber es gibt mehr!

Jetzt: Enums. Sind. Klassen.

Es fühlt sich so an, als hätten viele Junior-Entwickler noch nicht realisiert, wie mächtig Java-Enums wirklich sind.

In anderen Programmiersprachen sind sie wirklich nur verherrlichte Konstanten. Aber in Java sind sie praktische kleine Implementierungen eines Flyweight-Designmusters mit (viel von den) Vorteilen von voll ausgestatteten Klassen.

Warum ist das ein gutes Ding?

Test Fixture-bezogenes Verhalten

Wie jede andere Klasse können Enums Methoden hinzugefügt werden.

Dies wird praktisch, wenn Enum-Testparameter zwischen Tests wiederverwendet werden – dieselben Daten, nur ein bisschen anders getestet. Um effektiv mit den Parametern ohne erhebliches Kopieren und Einfügen zu arbeiten, muss einige Hilfscode zwischen diesen Tests geteilt werden.

Das ist nichts, das ein Helper-Class und einige statische Methoden nicht „lösen“ würden.

  • Randbemerkung: Beachten Sie, dass ilyenent Design an Feature-Neid leidet. Testmethoden — oder schlimmer noch, Hilfsklassenmethoden — müssten die Daten aus den Enum-Objekten ziehen, um Aktionen mit diesen Daten auszuführen.

While this is the (only) way in procedural programming, in the object-oriented world, we can do better.

Wenn wir die „Hilfs“-Methoden direkt in der Enum-Deklaration selbst angeben, würden wir den Code an den Ort verschieben, an dem die Daten sind. Oder umgangssprachlich ausgedrückt, die Hilfsmethoden würden zur „Verhaltensweise“ der als Enums implementierten Testumgebungen.

Dies würde nicht nur den Code einfacher und verständlicher machen (indem anstatt von statischen Methoden, die Daten umherreichen, sinnvolle Methoden auf Instanzen aufgerufen werden), sondern würde auch die Wiederverwendung von Enum-Parametern über verschiedene Testfälle hinweg erleichtern.

Inheritance

Enums können Schnittstellen mit (standardmäßigen) Methoden implementieren. Wenn dies vernünftig eingesetzt wird, kann dies dazu genutzt werden, das Verhalten zwischen mehreren Datenanbietern — mehreren Enums — zu teilen.

Ein Beispiel, das leicht einfällt, sind separate Enums für positive und negative Tests. Wenn sie eine ähnliche Art von Testumgebung darstellen, ist es wahrscheinlich, dass sie einige Verhaltensweisen teilen.

Das Gespräch ist billigLassen Sie uns dies anhand eines Test-Suites eines hypothetischen Quellcode-Datei-Konverters veranschaulichen, der nicht allzu sehr von dem Konverter unterscheidet, der Python 2 in Python 3 konvertiert.

Um wirkliches Vertrauen in das, was ein如此全面的工具 macht, würde man letztendlich ein umfangreiches Set von Eingabedateien haben, die verschiedene Aspekte der Sprache manifestieren, und passende Dateien, um das Umwandlungsergebnis dagegen zu vergleichen. Ausgenommen davon, ist es notwendig zu überprüfen, welche Warnungen/Fehler dem Benutzer für problematische Eingaben angezeigt werden.

Das ist eine natürliche Wahl für parametrisierte Tests aufgrund der großen Anzahl von Proben zu überprüfen, aber es passt nicht ganz zu einer der einfachen JUnit-Parameterquellen, da die Daten etwas komplex sind.

Siehe unten:

Java

 

    enum Conversion {
        CLEAN("imports-correct.2.py", "imports-correct.3.py", Set.of()),
        WARNINGS("problematic.2.py", "problematic.3.py", Set.of(
                "Using module 'xyz' that is deprecated"
        )),
        SYNTAX_ERROR("syntax-error.py", new RuntimeException("Syntax error on line 17"));
        // Viele, viele andere ...

        @Nonnull
        final String inFile;
        @CheckForNull
        final String expectedOutput;
        @CheckForNull
        final Exception expectedException;
        @Nonnull
        final Set expectedWarnings;

        Conversion(@Nonnull String inFile, @Nonnull String expectedOutput, @NotNull Set expectedWarnings) {
            this(inFile, expectedOutput, null, expectedWarnings);
        }

        Conversion(@Nonnull String inFile, @Nonnull Exception expectedException) {
            this(inFile, null, expectedException, Set.of());
        }

        Conversion(@Nonnull String inFile, String expectedOutput, Exception expectedException, @Nonnull Set expectedWarnings) {
            this.inFile = inFile;
            this.expectedOutput = expectedOutput;
            this.expectedException = expectedException;
            this.expectedWarnings = expectedWarnings;
        }

        public File getV2File() { ... }

        public File getV3File() { ... }
    }

    @ParameterizedTest
    @EnumSource
    void upgrade(Conversion con) {

        try {
            File actual = convert(con.getV2File());
            if (con.expectedException != null) {
                fail("No exception thrown when one was expected", con.expectedException);
            }
            assertEquals(con.expectedWarnings, getLoggedWarnings());
            new FileAssert(actual).isEqualTo(con.getV3File());
        } catch (Exception ex) {
            assertTypeAndMessageEquals(con.expectedException, ex);
        }
    }

Die Verwendung von Aufzählungen beschränkt uns nicht darin, wie komplex die Daten sein können. Wie Sie sehen können, können wir mehrere bequeme Konstruktoraufrufe in den Aufzählungen definieren, sodass die Deklaration neuer Parameter Sets schön und sauber ist. Dies verhindert die Verwendung langer Argumentenlisten, die oft mit vielen „leeren“ Werten (Null, leere Zeichenketten oder Sammlungen) gefüllt sind, die einen fragen lassen, was das siebte Argument — Sie wissen, eines der Nulls — eigentlich darstellt.

Beachten Sie, wie Aufzählungen die Verwendung komplexer Typen (Set, RuntimeException) ohne Einschränkungen oder magische Konvertierungen ermöglichen. Das Übergeben solcher Daten ist auch vollständig typensicher.

Jetzt weiß ich, was Sie denken. Das ist sehr umständlich. Nun, bis zu einem gewissen Grad. Realistisch gesehen werden Sie eine ganze Menge mehr Datensätze zur Überprüfung haben, sodass der Umfang des Bohnerwerks im Vergleich weniger bedeutsam sein wird.

Außerdem sehen Sie, wie verwandte Tests mit denselben Aufzählungen und ihren Helfermethoden geschrieben werden können:

Java

 

    @ParameterizedTest
    @EnumSource
    // Eine Aktualisierung von bereits aktualisierten Dateien wird stets bestanden, führt zu keinen Änderungen und gibt keine Warnungen aus.
    void upgradeFromV3toV3AlwaysPasses(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV3File());
    }

    @ParameterizedTest
    @EnumSource
    // Eine Degradierung von Dateien, die durch das Aktualisierungsverfahren erstellt wurden, wird erwartet, um stets ohne Warnungen bestanden zu werden.
    void downgrade(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV2File());
    }

Ein bisschen mehr Gespräch nach allem

Konzeptuell ermutigt @EnumSource Sie dazu, eine komplexe, maschinenlesbare Beschreibung einzelner Test-Szenarien zu erstellen, was die Grenze zwischen Datenanbietern und Testvorlagen verwirrt.

Eine andere großartige Sache daran, dass jeder Datensatz als Java-Symbol (Enum-Element) ausgedrückt wird, ist, dass sie einzeln verwendet werden können; vollständig unabhängig von Datenanbietern/parameterisierten Tests. Da sie einen vernünftigen Namen haben und selbstaufhaltsam sind (im Sinne von Daten und Verhalten), tragen sie zu schönen und lesbaren Tests bei.

Java

 

@Test
void warnWhenNoEventsReported() throws Exception {
    FixtureXmls.Invalid events = FixtureXmls.Invalid.NO_EVENTS_REPORTED;
    
    // read() ist eine Hilfsmethode, die von allen FixtureXmls geteilt wird.
    try (InputStream is = events.read()) {
        EventList el = consume(is);
        assertEquals(Set.of(...), el.getWarnings());
    }
}

Jetzt ist @EnumSource keineswegs eine Ihrer am häufigsten verwendeten Argumentquellen, und das ist eine gute Sache, da eineÜbernutzung nichts Gutes bringen würde. Aber in den richtigen Umständen ist es praktisch zu wissen, wie man all ihre Vorteile nutzen kann.

Source:
https://dzone.com/articles/junit5-parameterized-tests-with-enumsource