If you came from the TestNG course, you wrote data-driven tests with @DataProvider returning Object[][]. JUnit 5's answer is @ParameterizedTest combined with a source annotation. For simple single-value cases — a list of emails to validate, a set of status codes to check, a handful of role names — @ValueSource gets the job done in two lines. No extra class, no method returning a two-dimensional array, just the values inline.
The basic pattern
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
class EmailValidatorTest {
@ParameterizedTest
@ValueSource(strings = {"alice@test.com", "bob@domain.org", "charlie@example.co.uk"})
void shouldAcceptValidEmail(String email) {
assertTrue(EmailValidator.isValid(email));
}
}JUnit calls shouldAcceptValidEmail three times — once per string — and reports each execution separately in the test output:
EmailValidatorTest > shouldAcceptValidEmail(String) > [1] alice@test.com ✅
EmailValidatorTest > shouldAcceptValidEmail(String) > [2] bob@domain.org ✅
EmailValidatorTest > shouldAcceptValidEmail(String) > [3] charlie@example.co.uk ✅
When one input fails, only that execution is marked failed. The others remain as separate pass/fail results — which is exactly what you'd see in a TestNG @DataProvider report.
@ValueSource types
@ValueSource supports the eight primitive-compatible types plus Class:
// Integers
@ParameterizedTest
@ValueSource(ints = {200, 201, 204})
void shouldRecogniseSuccessStatusCodes(int statusCode) {
assertTrue(statusCode >= 200 && statusCode < 300);
}
// Longs
@ParameterizedTest
@ValueSource(longs = {1L, 999L, Long.MAX_VALUE})
void shouldHandleLargeIds(long id) {
assertDoesNotThrow(() -> repository.findById(id));
}
// Doubles
@ParameterizedTest
@ValueSource(doubles = {0.0, 9.99, 99.99, 999.99})
void shouldFormatPriceCorrectly(double price) {
String formatted = PriceFormatter.format(price);
assertTrue(formatted.startsWith("£"));
}
// Booleans
@ParameterizedTest
@ValueSource(booleans = {true, false})
void shouldHandleBothActiveStates(boolean active) {
User user = new User("Alice", "alice@test.com", active);
assertEquals(active, user.isActive());
}The strings variant is by far the most common in QA work. Use ints for HTTP status codes, response counts, and boundary values. Use doubles for price and percentage tests.
@NullSource, @EmptySource, and @NullAndEmptySource
Validating that a method rejects blank input is a separate concern from validating it accepts valid input. These three source annotations generate the "bad input" cases:
// @NullSource: passes null
// @EmptySource: passes "" for strings, empty array, empty list
// @NullAndEmptySource: combines both
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t", "\n"})
void shouldRejectBlankInput(String input) {
assertThrows(IllegalArgumentException.class,
() -> userService.createUser(input, "alice@test.com"));
}This runs five times: once with null, once with "", then once each for the three whitespace strings. The combination of @NullAndEmptySource and @ValueSource on the same test method is valid — JUnit merges the sources.
@EnumSource
For tests that must cover every value of an enum, @EnumSource prevents the test from becoming outdated when new enum values are added:
enum UserRole { ADMIN, EDITOR, VIEWER, GUEST }
@ParameterizedTest
@EnumSource(UserRole.class)
void shouldReturnNonNullPermissionsForAllRoles(UserRole role) {
assertNotNull(permissionService.getPermissions(role));
}This runs once per enum constant — four times for UserRole. If someone adds MODERATOR to the enum, the test automatically runs a fifth time on the next build. No test update required.
You can also include or exclude specific values:
@EnumSource(value = UserRole.class, names = {"ADMIN", "EDITOR"})
@EnumSource(value = UserRole.class, mode = EnumSource.Mode.EXCLUDE, names = {"GUEST"})The dependency you need
@ParameterizedTest and all source annotations live in junit-jupiter-params — a separate artifact. If you added junit-jupiter (the aggregate), it is already included transitively. If you added the split dependencies, add it explicitly:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>Missing this dependency causes a ClassNotFoundException at runtime or a compilation error on @ValueSource.
@ParameterizedTest vs TestNG @DataProvider
| TestNG @DataProvider | JUnit 5 @ParameterizedTest |
|---|---|
Returns Object[][] | Source annotations return typed values |
| One mechanism for all cases | Multiple source annotations by complexity |
@DataProvider(parallel = true) | Parallel via junit-platform.properties |
dataProvider = "name" links test to provider | Source annotation directly on test method |
External class needs static + dataProviderClass | @MethodSource for external classes (next lesson) |
For single-column data, @ValueSource is more concise than Object[][]. For multi-column data (email + password + expected status), use @CsvSource or @MethodSource — covered in the next two lessons.
How @ParameterizedTest works
Step 1 of 5
Source annotation is read
@ValueSource(strings = {"a@test.com", "b@test.com"}) is read before any test runs. JUnit determines the test will execute twice, once for each string.
⚠️ Common mistakes
- Forgetting
junit-jupiter-paramswhen using split dependencies. The annotation compiles because it is found on the classpath at build time (through test scope resolution), but at runtime JUnit cannot find the provider class and throws. If you getorg.junit.jupiter.params.ParameterizedTest is not annotated with @Test, this is the cause — add the params jar. - Using
@ValueSourcefor multi-column data.@ValueSourceprovides one value per invocation. If your test method has two parameters (String email, String password),@ValueSourcecannot supply both. Use@CsvSourcefor inline multi-column data or@MethodSourcefor complex objects. - Not combining
@NullAndEmptySourcewith@ValueSourcefor full blank coverage.@NullAndEmptySourcecoversnulland"". It does not cover whitespace-only strings like" ". For a method that usesString.isBlank()you need both@NullAndEmptySourceand@ValueSource(strings = {" "})together.
🎯 Practice task
Write a parameterised test suite for an input validator. 20–30 minutes.
- Create a
UsernameValidatorclass with a staticisValid(String username)method. Rules: 3–20 characters, alphanumeric and underscores only, not null, not blank. - Write
UsernameValidatorTestwith:- A
@ParameterizedTest @ValueSource(strings = {...})test with at least five valid usernames. - A
@ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "ab"})test for invalid inputs — assertassertFalse(isValid(input))or handle the null case withassertThrows. - A
@ParameterizedTest @ValueSource(ints = {3, 10, 20})boundary test checking exact valid lengths.
- A
- Run
mvn test. Confirm each invocation appears as a separate entry in the output. Count the total test executions — it should be the sum of all values across your source annotations. - Add
@EnumSource. Create aValidationLevelenum withSTRICT,NORMAL,LENIENT. Write a parameterised test that callsvalidator.validate(username, level)for all three levels and asserts the result is not null. - Stretch —
@ParameterizedTest(name = "..."). Addname = "[{index}] validating: {0}"to one of your tests. Run and confirm the report shows[1] validating: alice_99instead of the default format.
Next lesson: @CsvSource and @CsvFileSource — multi-parameter test data inline and from files.