Appearance
JUnit 5: Parameterized Tests (Part 1)
A Real World Problem: Testing the Same Logic with Different Data
Imagine you are building a system that validates usernames. The rule is simple: a username must be a palindrome (reads the same forwards and backwards). Your StringUtil.isPalindrome() method handles this check.
Now you need to test it. You could write five separate @Test methods — one for "civic", one for "level", one for "madam", one for "hello" (not a palindrome), and so on. That is five duplicate methods, each changing only the input. If your validation logic changes, you update five places. If you add ten more test cases, you add ten more methods.
There is a much better approach. JUnit 5 parameterized tests let you write one test method and declare all your test inputs separately. The framework runs that single method once for each input, each with a unique display name in the test report. This is exactly the same idea used in real production test suites when testing currency converters, tax calculators, discount engines, or any logic that needs verification against a table of inputs and expected outputs.
Parameterized vs Repeated Tests: The Critical Distinction
Before diving in, it is worth drawing a clear line:
| Feature | @RepeatedTest | @ParameterizedTest |
|---|---|---|
| Runs same test N times | Yes | Yes |
| Each run gets different input | No (same input always) | Yes (different input each run) |
| Practical use | Flaky test detection, timing | Testing logic across varied data |
@RepeatedTest re-executes with the same state. @ParameterizedTest feeds a fresh value on every invocation. The latter is far more useful for real validation scenarios.
The Required Dependency
To use @ParameterizedTest, add the JUnit 5 parameterized tests dependency to your pom.xml:
xml
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<scope>test</scope>
</dependency>If you use Spring Boot with spring-boot-starter-test, this is already included transitively.
Category 1: Single Parameter Sources
When your test method accepts one parameter, use either @ValueSource or @EnumSource.
@ValueSource — Inline Values for a Single Parameter
@ValueSource is the simplest source. You supply an array of literal values and the framework runs your test once per value.
java
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertTrue;
class StringUtilTest {
/**
* Runs three times: once with "civic", once with "level", once with "madam".
* Each value is injected into the 'word' parameter.
*/
@ParameterizedTest
@ValueSource(strings = {"civic", "level", "madam"})
void testIsPalindrome(String word) {
assertTrue(StringUtil.isPalindrome(word));
}
}Supported array types in @ValueSource:
strings— forStringparametersints— forint/Integerparameterslongs— forlong/Longparametersdoubles— fordouble/Doubleparametersfloats— forfloat/Floatshorts— forshortbytes— forbytechars— forcharclasses— forClass<?>parameters
@ValueSource does not support null or arbitrary objects.
Implicit Type Conversion
JUnit 5 automatically converts between compatible types. In the example below the test method accepts int, but the source supplies strings:
java
@ParameterizedTest
@ValueSource(strings = {"1", "2", "3"})
void testPositiveIntegers(int number) {
// JUnit 5 converts "1" -> 1, "2" -> 2, "3" -> 3 automatically
assertTrue(number > 0);
}This works for compatible conversions. However, if you supply "hello" where an int is expected, JUnit 5 throws ParameterResolutionException at runtime:
Failed to convert String "hello" to type intDo not rely on implicit conversion for production tests. If conversion is needed, do it explicitly.
Explicit Type Conversion with @ConvertWith
When you need to control exactly how a raw string value is transformed before being injected into your test parameter, implement ArgumentConverter:
java
import org.junit.jupiter.api.extension.ParameterContext;
import org.junit.jupiter.params.converter.ArgumentConverter;
/**
* Converts any incoming String to its uppercase form before injection.
*/
class StringUpperCaseConverter implements ArgumentConverter {
@Override
public Object convert(Object source, ParameterContext context) throws ArgumentConversionException {
if (source instanceof String str
&& context.getParameter().getType().equals(String.class)) {
return str.toUpperCase();
}
throw new ArgumentConversionException("Cannot convert " + source);
}
}Use it in your test with @ConvertWith:
java
@ParameterizedTest
@ValueSource(strings = {"hello", "world"})
void testWithExplicitConversion(@ConvertWith(StringUpperCaseConverter.class) String word) {
// word will be "HELLO" on first run, "WORLD" on second run
assertTrue(word.equals(word.toUpperCase()));
}Execution sequence:
@ValueSourceprovides raw string"hello".@ConvertWithtriggersStringUpperCaseConverter.convert().- The converted value
"HELLO"is injected intoword. - Test body executes.
Important: ArgumentConverter with @ConvertWith is only for single parameter methods. For multiple parameters, use ArgumentsAggregator (covered in Part 2).
Testing with Null and Empty Values
@ValueSource cannot supply null. JUnit 5 provides dedicated annotations for these edge cases:
java
import org.junit.jupiter.params.provider.EmptySource;
import org.junit.jupiter.params.provider.NullAndEmptySource;
import org.junit.jupiter.params.provider.NullSource;
class PalindromeEdgeCaseTest {
/**
* @NullAndEmptySource combines @NullSource and @EmptySource.
* Combined with @ValueSource, this test runs 5 times:
* run 1: null
* run 2: "" (empty string)
* run 3: "civic"
* run 4: "level"
* run 5: "madam"
*/
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {"civic", "level", "madam"})
void testPalindromeWithEdgeCases(String word) {
// null input should be handled gracefully by the production method
if (word == null || word.isEmpty()) {
assertFalse(StringUtil.isPalindrome(word));
return;
}
assertTrue(StringUtil.isPalindrome(word));
}
}| Annotation | Feeds |
|---|---|
@NullSource | A single null value |
@EmptySource | A single empty string "" |
@NullAndEmptySource | Both null and "" |
@EnumSource — Providing Enum Values
When your test method accepts an enum parameter, @EnumSource is the cleanest tool:
java
import org.junit.jupiter.params.provider.EnumSource;
import java.time.DayOfWeek;
class DayOfWeekTest {
/**
* Runs 7 times — once for each constant in DayOfWeek.
*/
@ParameterizedTest
@EnumSource(DayOfWeek.class)
void testAllDays(DayOfWeek day) {
assertNotNull(day);
}
/**
* Runs only for MONDAY and TUESDAY (inclusion mode, the default).
*/
@ParameterizedTest
@EnumSource(value = DayOfWeek.class, names = {"MONDAY", "TUESDAY"})
void testWeekdayStart(DayOfWeek day) {
assertTrue(day.getValue() <= 2);
}
/**
* Runs for all days EXCEPT MONDAY and TUESDAY (exclusion mode).
* mode = EnumSource.Mode.EXCLUDE reverses the filter.
*/
@ParameterizedTest
@EnumSource(
value = DayOfWeek.class,
names = {"MONDAY", "TUESDAY"},
mode = EnumSource.Mode.EXCLUDE
)
void testAllExceptWeekdayStart(DayOfWeek day) {
assertTrue(day.getValue() > 2);
}
}EnumSource.Mode options:
INCLUDE(default) — only the named constants are used.EXCLUDE— all constants except the named ones are used.MATCH_ALL— names are treated as regex patterns; all matching constants included.MATCH_ANY— names are regex patterns; any matching constant included.
Category 2: Multiple Parameter Sources
When your test method accepts two or more parameters, use @CsvSource or @CsvFileSource.
@CsvSource — Inline multi parameter Data
Each string inside @CsvSource represents one test run. Values within a string are comma separated (the default delimiter) and bound positionally to the test method parameters.
java
import org.junit.jupiter.params.provider.CsvSource;
class CalculatorTest {
/**
* Three runs:
* run 1: a=1, b=2, expected=3
* run 2: a=5, b=5, expected=10
* run 3: a=10, b=20, expected=30
*/
@ParameterizedTest
@CsvSource({
"1, 2, 3",
"5, 5, 10",
"10, 20, 30"
})
void testAddition(int a, int b, int expected) {
assertEquals(expected, a + b);
}
}Custom Delimiter
Replace the comma with any character using delimiter:
java
@ParameterizedTest
@CsvSource(
value = {"1 | 2 | 3", "5 | 5 | 10"},
delimiter = '|'
)
void testAdditionPipeDelimited(int a, int b, int expected) {
assertEquals(expected, a + b);
}Headers in Display Names
You can add a header row and use it to generate readable display names. Set useHeadersInDisplayName = true so JUnit skips that row instead of trying to parse it as data:
java
@ParameterizedTest
@CsvSource(
value = {
"valueOne, valueTwo, expectedValue", // header row
"1, 2, 3",
"5, 5, 10"
},
useHeadersInDisplayName = true
)
void testWithHeader(int a, int b, int expected) {
assertEquals(expected, a + b);
}Caution: do not combine useHeadersInDisplayName = true with numLinesToSkip for the same effect. Using both will skip two rows instead of one.
Values Containing Commas — Single Quotes
When a string value itself contains a comma, wrap it in single quotes so JUnit treats the entire sequence as one argument:
java
@ParameterizedTest
@CsvSource({"'Delhi, India', 11"})
void testCityWithComma(String city, int length) {
assertEquals(length, city.length());
}Null Values
You cannot write null directly inside a @CsvSource string — it would be treated as the literal word "null". Use nullValues to designate a sentinel:
java
@ParameterizedTest
@CsvSource(
value = {"N/A, 0", "hello, 5"},
nullValues = "N/A"
)
void testNullHandling(String word, int expected) {
// When word is "N/A" in the CSV, it arrives here as actual null
int length = (word == null) ? 0 : word.length();
assertEquals(expected, length);
}Controlling Whitespace
By default, JUnit 5 trims leading and trailing whitespace from each value. To preserve it:
java
@ParameterizedTest
@CsvSource(
value = {" hello , world "},
ignoreLeadingAndTrailingWhitespace = false
)
void testWhitespacePreserved(String a, String b) {
// a = " hello " (spaces preserved), b = " world " (spaces preserved)
assertTrue(a.startsWith(" "));
}@CsvFileSource — External CSV File for Large Data Sets
When you have hundreds of test rows, embedding them inline in Java source is unreadable. @CsvFileSource reads data from a file in src/test/resources/:
java
import org.junit.jupiter.params.provider.CsvFileSource;
class CalculatorFileTest {
/**
* Reads test data from src/test/resources/calculator-data.csv
* numLinesToSkip = 1 skips the header row in the file.
*/
@ParameterizedTest
@CsvFileSource(resources = "/calculator-data.csv", numLinesToSkip = 1)
void testAdditionFromFile(int a, int b, int expected) {
assertEquals(expected, a + b);
}
}src/test/resources/calculator-data.csv:
valueOne,valueTwo,expectedValue
1,2,3
5,5,10
10,20,30Alternatively, use useHeadersInDisplayName = true instead of numLinesToSkip = 1 — both skip the first line. Never use both together or you will inadvertently skip the second data row as well.
Controlling the Display Name
By default JUnit 5 generates a display name like [1] civic, [2] level, etc. You can fully customize this using placeholders inside the name attribute of @ParameterizedTest:
| Placeholder | Meaning |
|---|---|
{index} | Current invocation number (1-based) |
{arguments} | Comma separated list of all arguments |
{0}, {1}, ... | Individual argument by zero-based position |
{displayName} | Display name of the test method |
java
@ParameterizedTest(name = "Test {index}: isPalindrome({0})")
@ValueSource(strings = {"civic", "madam"})
void testPalindromeDisplayName(String word) {
assertTrue(StringUtil.isPalindrome(word));
}
// Produces: "Test 1: isPalindrome(civic)" and "Test 2: isPalindrome(madam)"
@ParameterizedTest(name = "Test {index}: {0} + {1} = {2}")
@CsvSource({"1, 2, 3", "5, 5, 10"})
void testAdditionDisplayName(int a, int b, int expected) {
assertEquals(expected, a + b);
}
// Produces: "Test 1: 1 + 2 = 3" and "Test 2: 5 + 5 = 10"Summary Table of Sources
| Annotation | Parameters | Data location |
|---|---|---|
@ValueSource | Single | Inline literal array |
@NullSource | Single | Supplies null |
@EmptySource | Single | Supplies "" |
@NullAndEmptySource | Single | Supplies both |
@EnumSource | Single (enum) | Enum constants |
@CsvSource | Multiple | Inline CSV strings |
@CsvFileSource | Multiple | External .csv file |
@MethodSource | Single or multiple | Static method (Part 2) |
@ArgumentsSource | Single or multiple | Custom class (Part 2) |
Interview Questions & Pitfalls
Q1. What is the difference between @RepeatedTest and @ParameterizedTest?
@RepeatedTest runs the same test the same number of times with identical state — useful for timing or flakiness detection. @ParameterizedTest runs the same test logic with a different input value on each invocation. In practice, @ParameterizedTest is far more valuable for correctness testing.
Q2. When does @ValueSource implicit type conversion fail?
Implicit conversion fails when the string cannot be parsed to the target type. For example, passing "hello" where an int is expected throws ParameterResolutionException with the message "Failed to convert String 'hello' to type int". Always verify that your source strings are compatible with the parameter type.
Q3. What annotation do you use for single parameter explicit type conversion? What about multiple parameters?
For a single parameter use @ConvertWith paired with a class implementing ArgumentConverter. For multiple parameters use @AggregateWith paired with a class implementing ArgumentsAggregator. Using the wrong one for the wrong arity is a common mistake.
Q4. How do you supply null as a test value using @CsvSource?
You cannot write null literally in a CSV string — it becomes the string "null". Instead, use the nullValues attribute of @CsvSource to designate a sentinel string (e.g., nullValues = "N/A") that will be converted to actual null before injection.
Q5. What happens if you use both numLinesToSkip = 1 and useHeadersInDisplayName = true on the same @CsvFileSource?
Both attributes skip one line. Together they skip two lines: the first via numLinesToSkip and then the (now) first remaining line via header parsing. This silently drops your first data row. Use one or the other, never both.
Q6. How do you include a comma inside a value when @CsvSource uses comma as the delimiter?
Wrap the value in single quotes: 'Delhi, India'. JUnit 5 treats everything inside single quotes as a single argument, ignoring delimiters within them.
Q7. What @EnumSource mode do you use to run tests for all enum constants except a few?
Use mode = EnumSource.Mode.EXCLUDE and list the names of the constants to skip in the names attribute. All other constants will be included.