@ParameterizedTest with @ValueSource

8 min read

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 @DataProviderJUnit 5 @ParameterizedTest
Returns Object[][]Source annotations return typed values
One mechanism for all casesMultiple source annotations by complexity
@DataProvider(parallel = true)Parallel via junit-platform.properties
dataProvider = "name" links test to providerSource 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-params when 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 get org.junit.jupiter.params.ParameterizedTest is not annotated with @Test, this is the cause — add the params jar.
  • Using @ValueSource for multi-column data. @ValueSource provides one value per invocation. If your test method has two parameters (String email, String password), @ValueSource cannot supply both. Use @CsvSource for inline multi-column data or @MethodSource for complex objects.
  • Not combining @NullAndEmptySource with @ValueSource for full blank coverage. @NullAndEmptySource covers null and "". It does not cover whitespace-only strings like " ". For a method that uses String.isBlank() you need both @NullAndEmptySource and @ValueSource(strings = {" "}) together.

🎯 Practice task

Write a parameterised test suite for an input validator. 20–30 minutes.

  1. Create a UsernameValidator class with a static isValid(String username) method. Rules: 3–20 characters, alphanumeric and underscores only, not null, not blank.
  2. Write UsernameValidatorTest with:
    • A @ParameterizedTest @ValueSource(strings = {...}) test with at least five valid usernames.
    • A @ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "ab"}) test for invalid inputs — assert assertFalse(isValid(input)) or handle the null case with assertThrows.
    • A @ParameterizedTest @ValueSource(ints = {3, 10, 20}) boundary test checking exact valid lengths.
  3. 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.
  4. Add @EnumSource. Create a ValidationLevel enum with STRICT, NORMAL, LENIENT. Write a parameterised test that calls validator.validate(username, level) for all three levels and asserts the result is not null.
  5. Stretch — @ParameterizedTest(name = "..."). Add name = "[{index}] validating: {0}" to one of your tests. Run and confirm the report shows [1] validating: alice_99 instead of the default format.

Next lesson: @CsvSource and @CsvFileSource — multi-parameter test data inline and from files.

// tip to track lessons you complete and pick up where you left off across devices.