Skip to main content
JUnit · lesson 4 of 7
JUnit intermediate 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

junit java parameterized-tests testing

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

  1. Add the row EU,1000,800 to rates.csv, predict whether it passes, then run RatesTest and 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.

  1. Write a parameterized test that checks only BLOCKED throws, using @EnumSource with names.
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 ✔
  1. Rewrite WeightTest to take a single ArgumentsAccessor and 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.

Frequently Asked Questions

How do I pass null or an empty string to a JUnit parameterized test?
Annotation values can't be null, so @ValueSource cannot supply null. Add @NullSource, @EmptySource or the combined @NullAndEmptySource to the test method; they add invocations on top of any other source such as @ValueSource(strings = {" "}). @EmptySource also supplies empty collections, arrays and maps when the parameter has that type.
How do I read JUnit test data from a CSV file?
Put the file under src/test/resources and annotate the test with @CsvFileSource(resources = "/rates.csv"). Each row becomes one invocation, and columns are converted to the parameter types automatically. Use numLinesToSkip = 1 or useHeadersInDisplayName = true for a header row; lines starting with # are comments.
What is @ParameterizedClass in JUnit?
@ParameterizedClass, added in JUnit 5.13 and part of JUnit 6, runs every test in a class once per argument set instead of repeating a single method. Arguments come from the usual sources such as @ValueSource or @EnumSource and are injected through the constructor or @Parameter fields. It suits contract tests that must hold for several inputs or implementations.
How do I give parameterized test invocations readable names?
Set the name attribute, for example @ParameterizedTest(name = "[{index}] {0} ships"), where {index} is the invocation number and {0} the first argument. With @FieldSource or @MethodSource you can wrap each row in Arguments.argumentSet("name", ...) so the invocation is named after the case. These names appear in IDEs, the console launcher and CI reports.
How do I convert a custom string format in a parameterized test?
Write a class that extends TypedArgumentConverter<String, Target> and implements convert, then annotate the parameter with @ConvertWith(YourConverter.class). JUnit passes the raw value through the converter before calling the test. For rows with many columns, an ArgumentsAccessor parameter lets the test read each column with getString, getInteger and similar methods.