JUnit · lesson 4 of 7
JUnit Parameterized Tests Beyond the Basics
JUnit 6 parameterized tests in depth: @EnumSource, null/empty sources, CSV files, @FieldSource, argument aggregation, display names, @ParameterizedClass.
By SkillByExample · Published
Parameterized tests run one test method many times with different arguments. The basics,
@ValueSource, @CsvSource and @MethodSource, are covered in
Unit Testing with JUnit 5 and Mockito; this lesson covers the other
sources, how arguments are named and converted, and JUnit 6’s @ParameterizedClass.
Seeing every invocation
Surefire reports how many tests ran, not their names. To see each invocation, this lesson uses
JUnit’s console launcher, which prints the test tree with display names. Download it into
target/ once, next to the compiled classes (mvn is the Docker alias from
lesson 1):
alias mvn='docker run --rm -v "$PWD":/w -v m2:/root/.m2 -w /w maven:3.10.0-eclipse-temurin-21-noble mvn'
alias junit='docker run --rm -v "$PWD":/w -w /w maven:3.10.0-eclipse-temurin-21-noble java -jar target/junit-platform-console-standalone-6.1.3.jar execute -cp target/classes:target/test-classes --disable-banner --disable-ansi-colors --details=tree --include-engine=junit-jupiter'
$ mvn -B -ntp -q dependency:copy -Dartifact=org.junit.platform:junit-platform-console-standalone:6.1.3 -DoutputDirectory=target
$ ls target
junit-platform-console-standalone-6.1.3.jar
dependency:copy fetched one self-contained jar that holds the JUnit Platform, every engine and
the launcher. It goes in target/, so mvn clean deletes it; rerun the command after a clean.
Each example then compiles with mvn -q test-compile and runs one class through junit. The
sed cuts the summary after the tree, whose first line is a timing.
Display names
The class under test prices a parcel by zone and weight: a base price per zone plus 100 for every full 500 grams. One zone refuses to ship at all.
// File: src/main/java/Shipping.java
public class Shipping {
public enum Zone { DOMESTIC, EU, WORLD, BLOCKED }
public static int cost(Zone zone, int grams) {
if (zone == Zone.BLOCKED) throw new IllegalArgumentException("cannot ship to " + zone);
int base = switch (zone) { case DOMESTIC -> 300; case EU -> 700; default -> 1500; };
return base + (grams / 500) * 100;
}
}
// File: src/test/java/ZoneTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ZoneTest {
@ParameterizedTest(name = "[{index}] {0} ships")
@EnumSource(value = Shipping.Zone.class, names = "BLOCKED", mode = EnumSource.Mode.EXCLUDE)
void everyOpenZoneHasAPrice(Shipping.Zone zone) {
assertTrue(Shipping.cost(zone, 100) > 0);
}
}
$ mvn -B -ntp -q test-compile && junit --select-class=ZoneTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ ZoneTest ✔
└─ everyOpenZoneHasAPrice(Zone) ✔
├─ [1] DOMESTIC ships ✔
├─ [2] EU ships ✔
└─ [3] WORLD ships ✔
@EnumSource supplies enum constants. With no options it would supply all four zones, including
BLOCKED, which throws; names plus mode = EXCLUDE leaves that one out. (mode = INCLUDE is
the default, and MATCH_ALL/MATCH_ANY take regular expressions.)
The name attribute sets each invocation’s display name: {index} is the invocation number
and {0} the first argument. Display names are what IDEs, the console launcher and CI reports
show, so a name that says what the row means makes a failure readable without opening the test.
Null, empty and blank input
A label must not be missing, empty or blank. That is five different inputs, and the first two
can’t be written in @ValueSource at all.
// File: src/main/java/Label.java
public class Label {
public static String normalise(String text) {
if (text == null || text.isBlank()) throw new IllegalArgumentException("label required");
return text.strip().toUpperCase();
}
}
// File: src/test/java/LabelTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertThrows;
class LabelTest {
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void rejectsMissingText(String text) {
assertThrows(IllegalArgumentException.class, () -> Label.normalise(text));
}
}
$ mvn -B -ntp -q test-compile && junit --select-class=LabelTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ LabelTest ✔
└─ rejectsMissingText(String) ✔
├─ [1] null ✔
├─ [2] "" ✔
├─ [3] " " ✔
├─ [4] "\t" ✔
└─ [5] "\n" ✔
@NullAndEmptySource added two invocations, null and "", in front of the three from
@ValueSource: sources on one method are combined. There are also @NullSource and
@EmptySource separately; “empty” adapts to the parameter type (an empty List, array or Map
works too). JUnit 6 quotes string arguments in display names and escapes control characters, which
is why the tab shows as " " and the blank as " " instead of invisible text.
Rows from a CSV file
Test data that grows, or that someone else maintains, belongs in a file. Under
src/test/resources it ends up on the test class path.
# File: src/test/resources/rates.csv
zone,grams,expected
DOMESTIC,100,300
DOMESTIC,1200,500
EU,499,700
WORLD,2000,1900
// File: src/test/java/RatesTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvFileSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class RatesTest {
@ParameterizedTest(name = "{arguments}")
@CsvFileSource(resources = "/rates.csv", useHeadersInDisplayName = true)
void cost(Shipping.Zone zone, int grams, int expected) {
assertEquals(expected, Shipping.cost(zone, grams));
}
}
$ mvn -B -ntp -q test-compile && junit --select-class=RatesTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ RatesTest ✔
└─ cost(Zone, int, int) ✔
├─ zone = "DOMESTIC", grams = "100", expected = "300" ✔
├─ zone = "DOMESTIC", grams = "1200", expected = "500" ✔
├─ zone = "EU", grams = "499", expected = "700" ✔
└─ zone = "WORLD", grams = "2000", expected = "1900" ✔
@CsvFileSource(resources = "/rates.csv") reads the file from the class path. JUnit treats lines
starting with # as comments, which is why the # File: line costs nothing.
useHeadersInDisplayName = true uses the first row as headers, so each display name reads
zone = "DOMESTIC", grams = "100", expected = "300" and the header row isn’t run as data. The
pattern {arguments} drops the [index] prefix.
Each column arrives as a String and JUnit converts it to the parameter type: "DOMESTIC" to
Shipping.Zone by enum name, "100" to int. That implicit conversion also covers long,
double, boolean, LocalDate, Duration, File and more.
Named argument sets with @FieldSource
@FieldSource reads arguments from a static field, and argumentSet gives each row a name.
// File: src/test/java/BoundaryTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.FieldSource;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.params.provider.Arguments.argumentSet;
class BoundaryTest {
static final List<Arguments> weights = List.of(
argumentSet("just under a step", 499, 300),
argumentSet("exactly one step", 500, 400),
argumentSet("empty parcel", 0, 300));
@ParameterizedTest
@FieldSource("weights")
void domesticSteps(int grams, int expected) {
assertEquals(expected, Shipping.cost(Shipping.Zone.DOMESTIC, grams));
}
}
$ mvn -B -ntp -q test-compile && junit --select-class=BoundaryTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ BoundaryTest ✔
└─ domesticSteps(int, int) ✔
├─ [1] just under a step ✔
├─ [2] exactly one step ✔
└─ [3] empty parcel ✔
Each invocation is named after its argument set: just under a step says why 499 grams was
chosen, where [1] 499, 300 would not. A field is shorter than a @MethodSource factory method
when the data is a fixed list. If the field isn’t named, @FieldSource looks for a field with the
same name as the test method.
Converting arguments
When a column needs custom parsing, write an ArgumentConverter and attach it to the parameter.
// File: src/test/java/KilogramConverter.java
import org.junit.jupiter.params.converter.TypedArgumentConverter;
class KilogramConverter extends TypedArgumentConverter<String, Integer> {
KilogramConverter() { super(String.class, Integer.class); }
@Override
protected Integer convert(String text) {
if (text.endsWith("kg")) {
return (int) (Double.parseDouble(text.replace("kg", "")) * 1000);
}
return Integer.parseInt(text.replace("g", ""));
}
}
// File: src/test/java/WeightTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.converter.ConvertWith;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class WeightTest {
@ParameterizedTest(name = "{0} -> {1} grams")
@CsvSource({"250g, 250", "1.5kg, 1500", "2kg, 2000"})
void parsesWeights(@ConvertWith(KilogramConverter.class) int grams, int expected) {
assertEquals(expected, grams);
}
}
$ mvn -B -ntp -q test-compile && junit --select-class=WeightTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ WeightTest ✔
└─ parsesWeights(int, int) ✔
├─ "250g" -> "250" grams ✔
├─ "1.5kg" -> "1500" grams ✔
└─ "2kg" -> "2000" grams ✔
@ConvertWith(KilogramConverter.class) routed the first column through convert, so "1.5kg"
reached the test as the int 1500. TypedArgumentConverter checks the source and target types for
you. The display names still show the raw CSV strings ("1.5kg"), because {0} formats the
argument before conversion. When a row has many columns, you can instead take one
ArgumentsAccessor parameter and read accessor.getInteger(1), accessor.getString(0) and so on
inside the test.
A whole class per argument: @ParameterizedClass
@ParameterizedTest repeats one method. @ParameterizedClass, new in JUnit 5.13 and part of
JUnit 6, repeats a whole class: every test in it runs once per argument.
// File: src/test/java/ZoneContractTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedClass;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.*;
@ParameterizedClass
@EnumSource(value = Shipping.Zone.class, names = {"DOMESTIC", "EU", "WORLD"})
class ZoneContractTest {
final Shipping.Zone zone;
ZoneContractTest(Shipping.Zone zone) { this.zone = zone; }
@Test void heavierCostsMore() {
assertTrue(Shipping.cost(zone, 2000) > Shipping.cost(zone, 100));
}
@Test void neverFree() {
assertNotEquals(0, Shipping.cost(zone, 0));
}
}
$ mvn -B -ntp -q test-compile && junit --select-class=ZoneContractTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ ZoneContractTest ✔
├─ [1] DOMESTIC ✔
│ ├─ heavierCostsMore() ✔
│ └─ neverFree() ✔
├─ [2] EU ✔
│ ├─ heavierCostsMore() ✔
│ └─ neverFree() ✔
└─ [3] WORLD ✔
├─ heavierCostsMore() ✔
└─ neverFree() ✔
The tree has a level that a parameterized method doesn’t: [1] DOMESTIC, [2] EU and [3] WORLD
are three invocations of the class, each holding both tests. JUnit passed each zone to the
constructor (it can also inject into fields marked @Parameter), so every test method is written
once and reads zone like an ordinary field. Six tests ran from two methods.
Use it when several tests share the same varying input: a contract that every implementation of
an interface must satisfy, or the same checks against several configurations. Any argument source
works on the class: @ValueSource, @CsvSource, @MethodSource and the others above.
Practice
- Add the row
EU,1000,800torates.csv, predict whether it passes, then runRatesTestand find the failing row in the tree.
Solution
# File: src/test/resources/rates.csv
zone,grams,expected
DOMESTIC,100,300
DOMESTIC,1200,500
EU,499,700
WORLD,2000,1900
EU,1000,800
$ mvn -B -ntp -q test-compile && junit --select-class=RatesTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ RatesTest ✔
└─ cost(Zone, int, int) ✔
├─ zone = "DOMESTIC", grams = "100", expected = "300" ✔
├─ zone = "DOMESTIC", grams = "1200", expected = "500" ✔
├─ zone = "EU", grams = "499", expected = "700" ✔
├─ zone = "WORLD", grams = "2000", expected = "1900" ✔
└─ zone = "EU", grams = "1000", expected = "800" ✘ expected: <800> but was: <900>
Failures (1):
JUnit Jupiter:RatesTest:cost(Zone, int, int):zone = "EU", grams = "1000", expected = "800"
MethodSource [className = 'RatesTest', methodName = 'cost', methodParameterTypes = 'Shipping$Zone, int, int']
=> org.opentest4j.AssertionFailedError: expected: <800> but was: <900>
org.junit.jupiter.api.Assertions.assertEquals(Assertions.java:569)
RatesTest.cost(RatesTest.java:10)
1000 grams is two full 500-gram steps, so the EU price is 700 + 200 = 900, not 800. The tree marks
only that invocation with ✘, and its display name tells you which row to fix without counting
lines in the CSV. The containers above it keep their ✔: a class or method node fails only for
its own errors, such as a broken source file, not because one of its invocations failed.
- Write a parameterized test that checks only
BLOCKEDthrows, using@EnumSourcewithnames.
Solution
// File: src/test/java/BlockedZoneTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.junit.jupiter.api.Assertions.assertThrows;
class BlockedZoneTest {
@ParameterizedTest
@EnumSource(value = Shipping.Zone.class, names = "BLOCKED")
void refusesToShip(Shipping.Zone zone) {
assertThrows(IllegalArgumentException.class, () -> Shipping.cost(zone, 100));
}
}
$ mvn -B -ntp -q test-compile && junit --select-class=BlockedZoneTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ BlockedZoneTest ✔
└─ refusesToShip(Zone) ✔
└─ [1] BLOCKED ✔
- Rewrite
WeightTestto take a singleArgumentsAccessorand parse the weight inside the test.
Solution
// File: src/test/java/WeightAccessorTest.java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.aggregator.ArgumentsAccessor;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class WeightAccessorTest {
@ParameterizedTest(name = "{0}")
@CsvSource({"250g, 250", "1.5kg, 1500", "2kg, 2000"})
void parsesWeights(ArgumentsAccessor row) {
String text = row.getString(0);
int grams = text.endsWith("kg")
? (int) (Double.parseDouble(text.replace("kg", "")) * 1000)
: Integer.parseInt(text.replace("g", ""));
assertEquals(row.getInteger(1), grams);
}
}
$ mvn -B -ntp -q test-compile && junit --select-class=WeightAccessorTest | sed '/^Test run finished/,$d'
╷
└─ JUnit Jupiter ✔
└─ WeightAccessorTest ✔
└─ parsesWeights(ArgumentsAccessor) ✔
├─ "250g" ✔
├─ "1.5kg" ✔
└─ "2kg" ✔
The accessor works, but the parsing now lives inside one test. The ArgumentConverter version
can be reused by any test that takes a weight.