Gids voor JUnit 5 geparametriseerde tests

1. Overzicht

JUnit 5, de volgende generatie JUnit, vergemakkelijkt het schrijven van ontwikkelaarstests met nieuwe en glanzende functies.

Een van die kenmerken is parameterized tests. Deze functie stelt ons in staat om voer een enkele testmethode meerdere keren uit met verschillende parameters.

In deze tutorial gaan we geparametriseerde tests diepgaand onderzoeken, dus laten we aan de slag gaan!

2. Afhankelijkheden

Om JUnit 5 geparametriseerde tests te gebruiken, moeten we het junit-jupiter-params artefact van JUnit Platform. Dat betekent dat wanneer u Maven gebruikt, we het volgende toevoegen aan onze pom.xml:

 org.junit.jupiter junit-jupiter-params 5.7.0 test 

Als we Gradle gebruiken, zullen we het ook een beetje anders specificeren:

testCompile ("org.junit.jupiter: junit-jupiter-params: 5.7.0")

3. Eerste indruk

Laten we zeggen dat we een bestaande nutsfunctie hebben en we zouden graag zeker willen zijn van zijn gedrag:

public class Numbers {public static boolean isOdd (int number) {return number% 2! = 0; }}

Geparametriseerde tests zijn net als andere tests, behalve dat we de @ParameterizedTest annotatie:

@ParameterizedTest @ValueSource (ints = {1, 3, 5, -3, 15, Integer.MAX_VALUE}) // zes getallen ongeldig isOdd_ShouldReturnTrueForOddNumbers (int getal) {assertTrue (Numbers.isOdd (getal)); }

De JUnit 5-testrunner voert deze bovenstaande test uit - en bijgevolg de is vreemd methode - zes keer. En elke keer wijst het een andere waarde toe dan de @ValueSource array naar de aantal method parameter.

Dit voorbeeld laat ons dus twee dingen zien die we nodig hebben voor een geparametriseerde test:

  • een bron van argumenten, een int array, in dit geval
  • een manier om ze te openen, in dit geval de aantal parameter

Er is ook nog een ding dat niet duidelijk is met dit voorbeeld, dus houd het in de gaten.

4. Argumentbronnen

Zoals we inmiddels zouden moeten weten, voert een geparametriseerde test dezelfde test meerdere keren uit met verschillende argumenten.

En hopelijk kunnen we meer doen dan alleen cijfers - dus laten we het verkennen!

4.1. Simpele waarden

Met de @ValueSource annotatie kunnen we een reeks letterlijke waarden doorgeven aan de testmethode.

Stel dat we onze eenvoud gaan testen is leeg methode:

public class Strings {public static boolean isBlank (String input) return input == null}

We verwachten van deze methode terug te keren waar voor nul voor lege strings. We kunnen dus een geparametriseerde test schrijven zoals de volgende om dit gedrag te bevestigen:

@ParameterizedTest @ValueSource (strings = {"", ""}) void isBlank_ShouldReturnTrueForNullOrBlankStrings (String-invoer) {assertTrue (Strings.isBlank (invoer)); } 

Zoals we kunnen zien, zal JUnit deze test twee keer uitvoeren en elke keer een argument uit de array toewijzen aan de method-parameter.

Een van de beperkingen van waardebronnen is dat ze alleen de volgende typen ondersteunen:

  • kort (met de korte broek attribuut)
  • byte (met de bytes attribuut)
  • int (met de ints attribuut)
  • lang (met de verlangt attribuut)
  • vlotter (met de drijft attribuut)
  • dubbele (met de verdubbelt attribuut)
  • char (met de tekens attribuut)
  • java.lang.String (met de snaren attribuut)
  • java.lang.Class (met de klassen attribuut)

Ook, we kunnen elke keer maar één argument aan de testmethode doorgeven.

En voordat we verder gingen, merkte iemand dat we niet waren gepasseerd nul als argument? Dat is een andere beperking: We kunnen er niet langs nul via een @ValueSource, zelfs voor Draad en Klasse!

4.2. Null en lege waarden

Vanaf JUnit 5.4 kunnen we een single passeren nul waarde toe aan een geparametriseerde testmethode met @NullSource:

@ParameterizedTest @NullSource void isBlank_ShouldReturnTrueForNullInputs (String-invoer) {assertTrue (Strings.isBlank (invoer)); }

Omdat primitieve gegevenstypen geen nul waarden, kunnen we de @NullSource voor primitieve argumenten.

Op dezelfde manier kunnen we lege waarden doorgeven met behulp van de @EmptySource annotatie:

@ParameterizedTest @EmptySource void isBlank_ShouldReturnTrueForEmptyStrings (String-invoer) {assertTrue (Strings.isBlank (invoer)); }

@EmptySource geeft een enkel leeg argument door aan de geannoteerde methode.

Voor Draad argumenten, zou de doorgegeven waarde zo simpel zijn als een lege waarde Draad. Bovendien kan deze parameterbron lege waarden leveren voor Verzameling typen en arrays.

Om beide te halen nul en lege waarden, kunnen we de samengestelde @NullAndEmptySource annotatie:

@ParameterizedTest @NullAndEmptySource void isBlank_ShouldReturnTrueForNullAndEmptyStrings (String-invoer) {assertTrue (Strings.isBlank (invoer)); }

Net als bij de @EmptySource, de samengestelde annotatie werkt voor Draads,Verzamelings, en arrays.

Om nog een paar lege stringvariaties door te geven aan de geparametriseerde test, we kunnen combineren @ValueSource, @NullSource en @EmptySource samen:

@ParameterizedTest @NullAndEmptySource @ValueSource (strings = {"", "\ t", "\ n"}) void isBlank_ShouldReturnTrueForAllTypesOfBlankStrings (String-invoer) {assertTrue (Strings.isBlank (invoer)); }

4.3. Enum

Om een ​​test uit te voeren met verschillende waarden uit een opsomming, kunnen we de @EnumSource annotatie.

We kunnen bijvoorbeeld stellen dat alle maandnummers tussen 1 en 12 liggen:

@ParameterizedTest @EnumSource (Month.class) // passeren alle 12 maanden ongeldig getValueForAMonth_IsAlwaysBetweenOneAndTwelve (maand maand) {int monthNumber = month.getValue (); assertTrue (monthNumber> = 1 && monthNumber <= 12); }

Of we kunnen een paar maanden filteren door de namen attribuut.

Hoe zit het met het beweren dat april, september, juni en november 30 dagen lang zijn:

@ParameterizedTest @EnumSource (waarde = Month.class, names = {"APRIL", "JUNI", "SEPTEMBER", "NOVEMBER"}) void someMonths_Are30DaysLong (Month month) {final boolean isALeapYear = false; assertEquals (30, month.length (isALeapYear)); }

Standaard is het namen behoudt alleen de overeenkomende enum-waarden. We kunnen dit omdraaien door de modus toe te schrijven aan UITSLUITEN:

@ParameterizedTest @EnumSource (waarde = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER", "FEBRUARY"}, mode = EnumSource.Mode.EXCLUDE) ongeldig behalveFourMonths_OthersAre31DaysLong (Month month) { laatste boolean isALeapYear = false; assertEquals (31, month.length (isALeapYear)); }

Naast letterlijke tekenreeksen kunnen we een reguliere expressie doorgeven aan de namen attribuut:

@ParameterizedTest @EnumSource (waarde = Month.class, names = ". + BER", mode = EnumSource.Mode.MATCH_ANY) ongeldig fourMonths_AreEndingWithBer (maand maand) {EnumSet months = EnumSet.of (Month.SEPTEMBER, Month.OCTOBER, Month .NOVEMBER, maand.DECEMBER); assertTrue (months.contains (maand)); }

Vrij gelijkaardig aan @ValueSource, @EnumSource is alleen van toepassing als we slechts één argument per testuitvoering gaan doorgeven.

4.4. CSV Literals

Stel dat we ervoor gaan zorgen dat de toUpperCase () methode van Draad genereert de verwachte waarde in hoofdletters. @ValueSource zal niet genoeg zijn.

Om een ​​geparametriseerde test voor dergelijke scenario's te schrijven, moeten we:

  • Geef een invoerwaarde en een verwachte waarde aan de testmethode
  • Bereken het feitelijk resultaat met die invoerwaarden
  • Beweren de werkelijke waarde met de verwachte waarde

We hebben dus argumentbronnen nodig die meerdere argumenten kunnen doorgeven. De @CsvSource is een van die bronnen:

@ParameterizedTest @CsvSource ({"test, TEST", "tEst, TEST", "Java, JAVA"}) void toUpperCase_ShouldGenerateTheExpectedUppercaseValue (String-invoer, String verwacht) {String actualValue = input.toUpperCase (); assertEquals (verwachte, werkelijke waarde); }

De @CsvSource accepteert een array van door komma's gescheiden waarden en elk array-item komt overeen met een regel in een CSV-bestand.

Deze bron neemt elke keer één array-invoer, splitst deze door komma's en geeft elke array als afzonderlijke parameters door aan de geannoteerde testmethode. Standaard is de komma het scheidingsteken voor kolommen, maar we kunnen deze aanpassen met de scheidingsteken attribuut:

@ParameterizedTest @CsvSource (waarde = {"test: test", "tEst: test", "Java: java"}, delimiter = ':') void toLowerCase_ShouldGenerateTheExpectedLowercaseValue (Stringinvoer, String verwacht) {String actualValue = input.toLowerCase ( ); assertEquals (verwachte, werkelijke waarde); }

Nu is het een dikke darm-gescheiden waarde, nog steeds een CSV!

4.5. CSV-bestanden

In plaats van de CSV-waarden in de code door te geven, kunnen we verwijzen naar een echt CSV-bestand.

We kunnen bijvoorbeeld een CSV-bestand gebruiken zoals:

invoer, verwachte test, TEST test, TEST Java, JAVA

We kunnen het CSV-bestand laden en negeer de koptekstkolom met @CsvFileSource:

@ParameterizedTest @CsvFileSource (resources = "/data.csv", numLinesToSkip = 1) ongeldig toUpperCase_ShouldGenerateTheExpectedUppercaseValueCSVFile (String-invoer, String verwacht) {String actualValue = input.toUpperCase (); assertEquals (verwachte, werkelijke waarde); }

De middelen attribuut staat voor de CSV-bestandsresources op het te lezen klassenpad. En we kunnen er meerdere bestanden aan doorgeven.

De numLinesToSkip attribuut staat voor het aantal regels dat moet worden overgeslagen bij het lezen van de CSV-bestanden. Standaard, @CsvFileSource slaat geen regels over, maar deze functie is meestal handig om de koptekstregels over te slaan, zoals we hier hebben gedaan.

Net als het simpele @CsvSource, kan het scheidingsteken worden aangepast met de scheidingsteken attribuut.

Naast de kolomscheiding:

  • Het lijnscheidingsteken kan worden aangepast met de lineSeparator attribuut - een nieuwe regel is de standaardwaarde
  • De bestandscodering kan worden aangepast met de codering attribuut - UTF-8 is de standaardwaarde

4.6. Methode

De argumentbronnen die we tot nu toe hebben behandeld, zijn enigszins eenvoudig en delen één beperking: het is moeilijk of onmogelijk om complexe objecten door te geven met behulp van deze bronnen!

Een benadering van het verstrekken van meer complexe argumenten is om een ​​methode als argumentbron te gebruiken.

Laten we de is leeg methode met een @MethodSource:

@ParameterizedTest @MethodSource ("offerStringsForIsBlank") void isBlank_ShouldReturnTrueForNullOrBlankStrings (String-invoer, boolean verwacht) {assertEquals (verwacht, Strings.isBlank (invoer)); }

De naam waaraan we leveren @MethodSource moet passen bij een bestaande methode.

Dus laten we het volgende schrijven offerStringsForIsBlank, een statisch methode die een Stroom van Arguments:

private static Stream offerStringsForIsBlank () {return Stream.of (Arguments.of (null, true), Arguments.of ("", true), Arguments.of ("", true), Arguments.of ("niet leeg", false)); }

Hier geven we letterlijk een reeks argumenten terug, maar het is geen strikte vereiste. Bijvoorbeeld, we kunnen elke andere verzameling-achtige interfaces retourneren, zoals Lijst.

Als we slechts één argument per testaanroep gaan geven, is het niet nodig om de Argumenten abstractie:

@ParameterizedTest @MethodSource // hmm, geen methode naam ... void isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument (String input) {assertTrue (Strings.isBlank (input)); } privé statische Stream isBlank_ShouldReturnTrueForNullOrBlankStringsOneArgument () {return Stream.of (null, "", ""); }

Als we geen naam opgeven voor het @MethodSource, Zoekt JUnit naar een bronmethode met dezelfde naam als de testmethode.

Soms is het handig om argumenten tussen verschillende testklassen te delen. In deze gevallen kunnen we naar een bronmethode buiten de huidige klasse verwijzen met de volledig gekwalificeerde naam:

klasse StringsUnitTest {@ParameterizedTest @MethodSource ("com.baeldung.parameterized.StringParams # blankStrings") void isBlank_ShouldReturnTrueForNullOrBlankStringsExternalSource (String-invoer) {assertTrue (Strings.isBlank (input)); }} openbare klasse StringParams {statische stroom blankStrings () {retour Stream.of (null, "", ""); }}

De ... gebruiken FQN # methodName formaat kunnen we verwijzen naar een externe statische methode.

4.7. Aangepaste argumentprovider

Een andere geavanceerde benadering om testargumenten te doorstaan, is het gebruik van een aangepaste implementatie van een interface met de naam Argumentenleverancier:

klasse BlankStringsArgumentsProvider implementeert ArgumentsProvider {@Override public Stream offerArguments (ExtensionContext context) {return Stream.of (Arguments.of ((String) null), Arguments.of (""), Arguments.of ("")); }}

Dan kunnen we onze test annoteren met de @ArgumentenSource annotatie om deze aangepaste provider te gebruiken:

@ParameterizedTest @ArgumentsSource (BlankStringsArgumentsProvider.class) void isBlank_ShouldReturnTrueForNullOrBlankStringsArgProvider (String-invoer) {assertTrue (Strings.isBlank (invoer)); }

Laten we de aangepaste provider een aangenamere API maken om te gebruiken met een aangepaste annotatie!

4.8. Aangepaste annotatie

Hoe zit het met het laden van de testargumenten van een statische variabele? Zoiets als:

static Stream arguments = Stream.of (Arguments.of (null, true), // null strings moeten als blanco worden beschouwd Arguments.of ("", true), Arguments.of ("", true), Arguments.of (" niet blanco ", false)); @ParameterizedTest @VariableSource ("argumenten") void isBlank_ShouldReturnTrueForNullOrBlankStringsVariableSource (String-invoer, boolean verwacht) {assertEquals (verwacht, Strings.isBlank (invoer)); }

Werkelijk, JUnit 5 biedt dit niet! We kunnen echter onze eigen oplossing rollen.

Ten eerste kunnen we een annotatie maken:

@Documented @Target (ElementType.METHOD) @Retention (RetentionPolicy.RUNTIME) @ArgumentsSource (VariableArgumentsProvider.class) public @interface VariableSource {/ ** * De naam van de statische variabele * / String value (); }

Dan moeten we op de een of andere manier consumeer de annotatie details en geef testargumenten. JUnit 5 biedt twee abstracties om die twee dingen te bereiken:

  • AnnotationConsumer om de annotatiedetails te gebruiken
  • ArgumentsProvider om testargumenten te geven

Dus we moeten nu de VariableArgumentsProvider klasse gelezen van de opgegeven statische variabele en retourneert de waarde ervan als testargumenten:

klasse VariableArgumentsProvider implementeert ArgumentsProvider, AnnotationConsumer {private String variableName; @Override public Stream offerArguments (ExtensionContext context) {return context.getTestClass () .map (this :: getField) .map (this :: getValue) .orElseThrow (() -> new IllegalArgumentException ("Kan testargumenten niet laden") ); } @Override public void accept (VariableSource variableSource) {variableName = variableSource.value (); } private Field getField (Class clazz) {probeer {return clazz.getDeclaredField (variableName); } catch (uitzondering e) {return null; }} @SuppressWarnings ("unchecked") privéstroom getValue (veldveld) {Objectwaarde = null; probeer {waarde = field.get (null); } catch (uitzondering genegeerd) {} ​​retourwaarde == null? null: (Stream) waarde; }}

En het werkt als een zonnetje!

5. Argumentconversie

5.1. Impliciete conversie

Laten we er een herschrijven @EnumTests met een @CsvBron:

@ParameterizedTest @CsvSource ({"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) // Pssing-strings zijn ongeldig someMonths_Are30DaysLongCsv (maand maand) {final boolean isALeapYear = false; assertEquals (30, month.length (isALeapYear)); }

Dit zou niet moeten werken, toch? Maar op de een of andere manier wel!

Dus JUnit 5 converteert de Draad argumenten voor het opgegeven enum-type. Om gebruiksscenario's als deze te ondersteunen, biedt JUnit Jupiter een aantal ingebouwde impliciete typeconverters.

Het conversieproces is afhankelijk van het gedeclareerde type van elke methodeparameter. De impliciete conversie kan het Draad instanties voor typen zoals:

  • UUID
  • Locale
  • LocalDate, LocalTime, LocalDateTime, jaar, maand, etc.
  • het dossier en Pad
  • URL en URI
  • Enum subklassen

5.2. Expliciete conversie

Soms moeten we een aangepaste en expliciete conversie voor argumenten opgeven.

Stel dat we strings willen converteren met de jjjj / mm / ddformaat naar LocalDate gevallen. Ten eerste moeten we het ArgumentConverter koppel:

class SlashyDateConverter implementeert ArgumentConverter {@Override public Object convert (Object source, ParameterContext context) gooit ArgumentConversionException {if (! (source instanceof String)) {throw new IllegalArgumentException ("Het argument moet een string zijn:" + source); } probeer {String [] parts = ((String) source) .split ("/"); int year = Integer.parseInt (delen [0]); int maand = Integer.parseInt (delen [1]); int day = Integer.parseInt (delen [2]); retourneer LocalDate.of (jaar, maand, dag); } catch (uitzondering e) {throw nieuwe IllegalArgumentException ("Converteren mislukt", e); }}}

Dan moeten we naar de converter verwijzen via de @ConvertWith annotatie:

@ParameterizedTest @CsvSource ({"2018/12 / 25,2018", "2019/02 / 11,2019"}) ongeldig getYear_ShouldWorkAsExpected (@ConvertWith (SlashyDateConverter.class) LocalDate-datum, int verwacht) {assertEquals (verwacht, datum. getYear ()); }

6. Argument Accessor

Standaard komt elk argument dat aan een geparametriseerde test wordt verstrekt, overeen met een enkele methodeparameter. Bijgevolg, wanneer een handvol argumenten via een argumentbron wordt doorgegeven, wordt de handtekening van de testmethode erg groot en rommelig.

Een manier om dit probleem aan te pakken, is door alle doorgegeven argumenten in een instantie van ArgumentenAccessor en haal argumenten op met index en type.

Laten we bijvoorbeeld eens kijken naar onze Persoon klasse:

klasse Persoon {String firstName; String middleName; String achternaam; // constructor public String fullName () {if (middleName == null || middleName.trim (). isEmpty ()) {return String.format ("% s% s", voornaam, achternaam); } return String.format ("% s% s% s", voornaam, middelste naam, achternaam); }}

Om vervolgens het voor-en achternaam() methode, zullen we vier argumenten doorgeven: voornaam tweede naam achternaam, en de verwachte volledige naam. We kunnen de ArgumentenAccessor om de testargumenten op te halen in plaats van ze als methodeparameters te declareren:

@ParameterizedTest @CsvSource ({"Isaac ,, Newton, Isaac Newton", "Charles, Robert, Darwin, Charles Robert Darwin"}) void fullName_ShouldGenerateTheExpectedFullName (ArgumentsAccessor argumentsAccessor) {String firstName = argumentsAccessor.getString (0); String middleName = (String) argumentsAccessor.get (1); String lastName = argumentsAccessor.get (2, String.class); String verwachteFullName = argumentsAccessor.getString (3); Persoon person = nieuwe persoon (voornaam, middelste naam, achternaam); assertEquals (verwachteFullNaam, persoon.fullNaam ()); }

Hier vatten we alle doorgegeven argumenten in een ArgumentenAccessor instantie en vervolgens, in de hoofdtekst van de testmethode, elk doorgegeven argument met zijn index ophalen. Behalve dat het alleen een accessor is, wordt typeconversie ondersteund via krijgen* methoden:

  • getString (index) haalt een element op een specifieke index op en converteert het naar Draadthetzelfde geldt voor primitieve typen
  • krijgen (index) haalt eenvoudig een element op een specifieke index op als een Voorwerp
  • krijgen (index, type) haalt een element op een specifieke index op en converteert het naar het gegeven type

7. Argumentaggregator

De ... gebruiken ArgumentenAccessor direct abstractie kan de testcode minder leesbaar of herbruikbaar maken. Om deze problemen aan te pakken, kunnen we een aangepaste en herbruikbare aggregator schrijven.

Om dat te doen, implementeren we het ArgumentsAggregator koppel:

klasse PersonAggregator implementeert ArgumentsAggregator {@Override public Object aggregateArguments (ArgumentsAccessor accessor, ParameterContext context) gooit ArgumentsAggregationException {return new Person (accessor.getString (1), accessor.getString (2), accessor.getString (3)); }}

En dan verwijzen we ernaar via de @AggregateWith annotatie:

@ParameterizedTest @CsvSource ({"Isaac Newton, Isaac ,, Newton", "Charles Robert Darwin, Charles, Robert, Darwin"}) void fullName_ShouldGenerateTheExpectedFullName (String verwachteFullNaam, @AggregateWith (PersonAggregator.class) Persoon persoon) {assertEquals (verwachteFullNaam, person.fullName ()); }

De PersonAggregator neemt de laatste drie argumenten en instantieert a Persoon klasse uit hen.

8. Weergavenamen aanpassen

Standaard bevat de weergavenaam voor een geparametriseerde test een aanroepindex samen met een Draad vertegenwoordiging van alle doorgegeven argumenten, zoiets als:

├─ someMonths_Are30DaysLongCsv (Month) │ │ ├─ [1] APRIL │ │ ├─ [2] JUNI │ │ ├─ [3] SEPTEMBER │ │ └─ [4] NOVEMBER

We kunnen deze weergave echter aanpassen via de naam attribuut van de @ParameterizedTest annotatie:

@ParameterizedTest (name = "{index} {0} is 30 dagen lang") @EnumSource (waarde = Month.class, names = {"APRIL", "JUNE", "SEPTEMBER", "NOVEMBER"}) someMonths_Are30DaysLong ( Maand maand) {laatste boolean isALeapYear = false; assertEquals (30, month.length (isALeapYear)); }

April is 30 dagen lang is zeker een beter leesbare weergavenaam:

├─ someMonths_Are30DaysLong (Month) │ │ ├─ 1 APRIL is 30 dagen lang │ │ ├─ 2 JUNI is 30 dagen lang │ │ ├─ 3 SEPTEMBER is 30 dagen lang │ │ └─ 4 NOVEMBER is 30 dagen lang

De volgende tijdelijke aanduidingen zijn beschikbaar bij het aanpassen van de weergavenaam:

  • {inhoudsopgave} wordt vervangen door de aanroepindex - simpel gezegd, de aanroepindex voor de eerste uitvoering is 1, voor de tweede is 2, enzovoort
  • {argumenten} is een tijdelijke aanduiding voor de volledige, door komma's gescheiden lijst met argumenten
  • {0}, {1}, ... zijn tijdelijke aanduidingen voor individuele argumenten

9. Conclusie

In dit artikel hebben we de moeren en bouten van geparametriseerde tests in JUnit 5 onderzocht.

We hebben geleerd dat geparametriseerde tests in twee opzichten verschillen van normale tests: ze zijn geannoteerd met de @ParameterizedTest, en ze hebben een bron nodig voor hun gedeclareerde argumenten.

Ook zouden we nu moeten hebben dat JUnit enkele faciliteiten biedt om de argumenten naar aangepaste doeltypes om te zetten of om de testnamen aan te passen.

Zoals gewoonlijk zijn de voorbeeldcodes beschikbaar op ons GitHub-project, dus zorg ervoor dat je het bekijkt!