Testparametrisering met JUnit 5.7: Een diepgaande kijk in @EnumSource

Parametriseerde tests stellen ontwikkelaars in staat om hun code efficiënt te testen met een reeks invoerwaarden. In het domein van JUnit testen, hebben ervaren gebruikers lang gestreden met de complexiteiten van het implementeren van deze tests. Maar met de release van JUnit 5.7, breekt een nieuwe tijdperk van testparametrisatie aan, die ontwikkelaars first-class ondersteuning en verbeterde mogelijkheden biedt. Laten we duiken in de spannende mogelijkheden die JUnit 5.7 brengt voor parametriseerd testen!

Paraametrisatie Voorbeelden Uit JUnit 5.7 Docs

Laten we eens kijken naar wat voorbeelden uit de documentatie:

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"))
    );
}

De annotatie @ParameterizedTest moet worden vergezeld gaan van een van de verschillende geleverde bronannotaties die beschrijven waar de parameters vandaan moeten komen. De bron van de parameters wordt vaak de “data provider” genoemd.

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:

  • De @ValueSource is beperkt tot het leveren van slechts één parameterwaarde. Met andere woorden, de testmethode kan niet meer dan één argument hebben, en de types die men kan gebruiken zijn beperkt ook.
  • Het doorgeven van meerdere argumenten wordt gedeeltelijk aangepakt door @CsvSource, waarbij elke string wordt opgesplitst in een record dat vervolgens veld voor veld als argumenten wordt doorgegeven. Dit kan moeilijk leesbaar worden met lange strings en/of veel argumenten. De types die men kan gebruiken zijn ook beperkt — hierover later meer.
  • Alle bronnen die de feitelijke waarden in annotaties declareren, zijn beperkt tot waarden die compile-tijd constanten zijn (beperking van Java annotaties, niet JUnit).
  • @MethodSource en @ArgumentsSource bieden een stream/collectie van (ongetypeerde) n-tupels die vervolgens worden doorgegeven als methode-argumenten. Verschillende feitelijke typen worden ondersteund om de reeks van n-tupels te vertegenwoordigen, maar geen van hen garandeert dat ze in de argumentenlijst van de methode zullen passen. Dit soort bron vereist extra methoden of klassen, maar stelt geen beperkingen op waar en hoe de testgegevens worden verkregen.

Zoals je kunt zien, variëren de beschikbare bronnen van de eenvoudige (gemakkelijk te gebruiken, maar beperkt in functionaliteit) tot de uiterst flexibele die meer code vereisen om werkend te krijgen.

  • Sidenoote — Dit is meestal een teken van goed ontwerp: een beetje code is nodig voor essentiële functionaliteit, en het toevoegen van extra complexiteit is gerechtvaardigd wanneer het wordt gebruikt om een meer veeleisend gebruiksscenario mogelijk te maken.

Wat niet lijkt te passen in dit hypothetische eenvoudig-naar-flexibel continuüm, is @EnumSource. Kijk eens naar dit niet-triviale voorbeeld van vier parameter sets met elk 2 waarden.

  • Opmerking — Hoewel @EnumSource de waarde van de enum doorgeeft als een enkele testmethodeparameter, conceptueel gezien, is de test ge参数iseerd door de velden van de enum, dat stelt geen beperking op het aantal parameters.
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());
    }

Denk er maar eens over na: de hardcoded lijst van waarden beperkt de flexibiliteit ernstig (geen externe of gegenereerde gegevens), terwijl de hoeveelheid extra code die nodig is om het enum te declareren een tamelijk uitgebreide keuze is in vergelijking met, bijvoorbeeld, @CsvSource.

Maar dat is slechts een eerste indruk. We zullen zien hoe elegant dit kan worden wanneer we de ware kracht van Java enums benutten.

  • Terzijde: Dit artikel gaat niet in op de verificatie van enums die onderdeel uitmaken van uw productiecode. Die dienden uiteraard te worden gedeclareerd, ongeacht hoe u ervoor kiest ze te verifiëren. In plaats daarvan richt het zich op wanneer en hoe u uw testgegevens uitdrukt in de vorm van enums.

Wanneer te gebruiken

Er zijn situaties waarin enums beter presteren dan de alternatieven:

Meerdere parameters per test

Als je slechts één parameter nodig hebt, wil je waarschijnlijk niet verder ingewikkeld maken dan @ValueSource. Maar zodra je meerdere parameters nodig hebt – bijvoorbeeld, ingangen en verwachte resultaten – moet je terugvallen op @CsvSource, @MethodSource/@ArgumentsSource of @EnumSource.

In zekere zin laat een enum je “smokkelen” een willekeurig aantal gegevensvelden.

Dus wanneer je in de toekomst meer testmethodenparameters moet toevoegen, voeg je eenvoudig meer velden toe aan je bestaande enums, zonder de handtekeningen van de testmethoden aan te passen. Dit wordt onmisbaar wanneer je je gegevensprovider in meerdere tests hergebruikt.

Voor andere bronnen moet men ArgumentsAccessors of ArgumentsAggregators gebruiken voor de flexibiliteit die enums standaard bieden.

Typveiligheid

Voor Java-ontwikkelaars zou dit een groot voordeel moeten zijn.

Parameters die gelezen worden uit CSV (bestanden of literals), @MethodSource of @ArgumentsSource, bieden geen compileertijdgarantie dat het aantal parameters en hun types overeenkomen met de signatuur.

Uiteraard zal JUnit een klacht indienen tijdens het uitvoeren, maar vergeten we dan ook elke codehulp van je IDE.

Net als ervoor, dit komt samen wanneer je dezelfde parameters hergebruikt voor meerdere tests. Een typeveilige benadering zou een huge win zijn bij het uitbreiden van de parameter set in de toekomst.

Custom Types

Dit is vooral een voordeel boven tekstgebaseerde bronnen, zoals degenen die gegevens lezen uit CSV — de in tekst gecodeerde waarden moeten worden geconverteerd naar Java-typen.

Als je een aangepaste klasse hebt om te instantiëren van de CSV-regel, kun je dat doen met ArgumentsAggregator. Echter, je gegevensverklaring is nog steeds niet typeveilig — elke mismatch tussen de methodesignatuur en de verklarde gegevens zal opduiken tijdens het “aggregateren” van argumenten. Laat staan dat het declareren van de aggregator-klasse meer ondersteunende code toevoegt die nodig is voor je parameterisatie om te werken. En we hebben altijd de voorkeur gegeven aan @CsvSource boven @EnumSource om de extra code te vermijden.

Documenteerbaar

In tegenstelling tot de andere methoden, heeft de enum-bron Java-symbols voor zowel parameter sets (enum-instanties) als alle parameters die ze bevatten (enum-velden). Ze bieden een eenvoudige plek waar documentatie in zijn meer natuurlijke vorm kan worden gekoppeld – de JavaDoc.

Het is niet dat documentatie niet ergens anders kan worden geplaatst, maar het zal – door definitie – verder van wat het documenteert worden geplaatst en daardoor moeilijker te vinden en gemakkelijker verouderd raken.

Maar er is meer!

Nu: Enums. Zijn. Classes.

Het lijkt erop dat veel junior ontwikkelaars nog steeds niet beseffen hoe krachtig Java enums werkelijk zijn.

In andere programmeertalen zijn ze echt alleen maar opgepoetste constanten. Maar in Java zijn ze handige kleine implementaties van een Flyweight design pattern met (veel van de) voordelen van volwaardige klassen.

Waarom is dat een goed ding?

Test Fixture-gerelateerd Gedrag

Net als elke andere klasse kunnen enums methoden toegevoegd worden.

Dit komt van pas als enum testparameters tussen tests worden hergebruikt – dezelfde gegevens, maar een beetje anders getest. Om effectief met de parameters te werken zonder significant kopiëren en plakken, moet enkele hulpcode tussen die tests worden gedeeld.

Het is niet iets dat een hulpklasse en een paar statische methoden niet zouden “oplossen”.

  • Naamswijziging: Merk op dat een dergelijk ontwerp kampt met Feature Envy. Testmethoden – of erger nog, hulpklassemethoden – zouden de gegevens uit de enum-objecten moeten halen om acties uit te voeren op die gegevens.

Hoewel dit de (enige) manier is in proceduraal programmeren, kunnen we in de objectgeoriënteerde wereld beter doen.

Door de “hulp”methoden direct in de enum-declaratie zelf te verklaren, verplaatsen we de code naar waar de gegevens zich bevinden. Of, om het in OOP-termen te zeggen, de hulpmethoden zouden de “gedrag” van de testfiksen kunnen worden die zijn geïmplementeerd als enums. Dit zou niet alleen de code meer idiomatisch maken (het aanroepen van verstandige methoden op instanties in plaats van statische methoden die gegevens rondstuuren), maar het zou ook gemakkelijker maken om enum-parameters over testgevallen heen te hergebruiken.

Erfgenoegschap

Enums kunnen interfaces implementeren met (standaard) methoden. Wanneer dit verstandig wordt gebruikt, kan dit worden ingezet om gedrag tussen verschillende gegevensproviders – verschillende enums – te delen.

Een voorbeeld dat gemakkelijk te indenken is, zijn aparte enums voor positieve en negatieve tests. Als ze een vergelijkbare soort testfiks vertegenwoordigen, is de kans groot dat ze enkele gedragingen gemeen hebben.

De Praktijk Is Het Woord

Laten we dit illustreren aan de hand van een testsuites van een hypothetische omzetter van broncodebestanden, niet al te verschillend van degene die Python 2 naar 3 omzet.

Om echt vertrouwen te hebben in wat zo’n uitgebreide tool doet, zou men uiteindelijk een uitgebreide set invoerbestanden hebben die verschillende aspecten van de taal vertegenwoordigen, en overeenkomende bestanden om de conversieresultaten mee te vergelijken. Behalve dat, is het nodig om te verifiëren welke waarschuwingen/fouten aan de gebruiker worden gegeven voor problematische invoeren.

Dit is een natuurlijke fit voor geparametriseerde tests vanwege het grote aantal monsters dat moet worden geverifieerd, maar het past niet goed bij een van de eenvoudige JUnit parameterbronnen, omdat de gegevens tamelijk complex zijn.

Zie hieronder:

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"));
        // Veel, veel anderen ...

        @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);
        }
    }

Het gebruik van enums beperkt ons niet in hoe complex de gegevens kunnen zijn. Zoals je kunt zien, kunnen we meerdere handige constructors in de enums definiëren, zodat het declareren van nieuwe parametersets netjes en overzichtelijk is. Dit voorkomt het gebruik van lange argumentenlijsten die vaak vol zitten met veel “lege” waarden (nulls, lege strings of collecties) die je doen twijfelen aan wat argument #7 – je weet wel, een van de nulls – eigenlijk vertegenwoordigt.

Merk op hoe enums het gebruik van complexe typen (Set, RuntimeException) mogelijk maken zonder beperkingen of magische conversies. Het doorgeven van dergelijke gegevens is ook volledig type-veilig.

Nu weet ik wat je denkt. Dit is erg woordig. Wel, tot op zekere hoogte. Realistisch gezien ga je veel meer gegevensmonsters moeten verifiëren, dus het volume van de standaardcode zal minder significant zijn in vergelijking.

zie ook hoe gerelateerde tests kunnen worden geschreven door gebruik te maken van dezelfde enums, en hun hulpmethoden:

Java

 

    @ParameterizedTest
    @EnumSource
    // Het upgraden van reeds geüpgradede bestanden gaat altijd goed, brengt geen wijzigingen aan, geeft geen waarschuwingen.
    void upgradeFromV3toV3AlwaysPasses(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV3File());
    }

    @ParameterizedTest
    @EnumSource
    // Het downgraden van bestanden gemaakt door de upgradeprocedure wordt verwacht om altijd zonder waarschuwingen te slagen.
    void downgrade(Conversion con) throws Exception {
        File actual = convert(con.getV3File());
        assertEquals(Set.of(), getLoggedWarnings());
        new FileAssert(actual).isEqualTo(con.getV2File());
    }

Some More Talk After All

Conceptueel gezien moedigt @EnumSource je aan om een complexe, machine-leesbare beschrijving van individuele test scenario’s te maken, waardoor de lijn tussen data providers en test fixtures verduistert.

Een ander groot voordeel van het hebben van elke dataset uitgedrukt als een Java-symbool (enum-element) is dat ze afzonderlijk gebruikt kunnen worden; volledig buiten data providers/parameterized tests. Omdat ze een redelijke naam hebben en zelfstandig zijn (wat betreft gegevens en gedrag), dragen ze bij aan nette en leesbare tests.

Java

 

@Test
void warnWhenNoEventsReported() throws Exception {
    FixtureXmls.Invalid events = FixtureXmls.Invalid.NO_EVENTS_REPORTED;
    
    // read() is een hulpmethode die door alle FixtureXmls gedeeld wordt
    try (InputStream is = events.read()) {
        EventList el = consume(is);
        assertEquals(Set.of(...), el.getWarnings());
    }
}

Nu, @EnumSource zal geen een van je meest gebruikte argumentbronnen zijn, en dat is een goed ding, omdat overmatig gebruik geen goed doet. Maar in de juiste omstandigheden is het handig om te weten hoe je al hun mogelijkheden kunt gebruiken.

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