Writing the same test five times with different inputs is copying, not testing. Parameterized testing breaks that habit by letting you write a single assertion once and run it against as many datasets as the problem demands. JUnit 6 (the Jupiter framework) gives you a rich set of argument sources, clean display names, and enough flexibility to handle everything from a handful of strings to deeply nested domain objects.

For developers building Spring Boot services, getting good at parameterized testing pays compound interest. Service layer logic, validation rules, pricing calculations, and repository queries all share the same structure: given some input, expect some output. Parameterized tests make that pattern explicit and give you a test suite that grows with your domain model.

Most developers run into friction with complex inputs: DTOs, entity objects, configuration records, and the boilerplate required to construct them in Java.

This guide walks through every major parameter source in JUnit 6, shows when and why to reach for each one, covers the specific patterns that surface in Spring Boot test suites across every layer (service, controller, repository, and configuration), and then demonstrates how Kotlin's data classes and named arguments cut test setup code without touching a single line of production Java.

What parameterized tests are

A parameterized test is a test method that JUnit runs multiple times, injecting a different set of arguments on each invocation. You write the assertion logic once, and the framework handles the loop. Each invocation is tracked, named, and reported independently in the test runner, so a failure on input three doesn't mask a pass on input five.

The key annotation is @ParameterizedTest, which replaces @Test. You pair it with a source annotation that tells JUnit where to pull argument values from. JUnit 6 ships with sources for inline values, CSV rows, CSV files, factory methods, and pluggable providers, each suited to a different complexity tier.

Setup for parameterized tests

Required dependencies

If you're starting from a Spring Boot project, spring-boot-starter-test already pulls in the JUnit Jupiter engine. You need to add junit-jupiter-params explicitly if it's not coming in transitively from your BOM.

JUnit 6 raises the minimum Java version to 17 (JUnit 5 only required 8), and if you're writing tests in Kotlin, you'll need Kotlin 2.2 or newer. Spring Boot 4.x already baselines on Java 17 and manages compatible JUnit and Kotlin versions for you, so the simplest path is to build on Spring Boot 4.

Gradle (Kotlin DSL):

plugins { id("org.springframework.boot") version "4.1.1" id("io.spring.dependency-management") version "1.1.7" kotlin("jvm") version "2.3.21" kotlin("plugin.spring") version "2.3.21" } dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.jetbrains.kotlin:kotlin-reflect") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.junit.jupiter:junit-jupiter-params") } tasks.withType<Test> { useJUnitPlatform() } 

For Maven, add junit-jupiter-params with test scope alongside spring-boot-starter-test.

IDE support

IntelliJ IDEA renders each parameterized invocation as a child node in the Run tool window, using whatever display name you configure. It warns you at edit time if you declare @ParameterizedTest without a source, highlights type mismatches between the source and the method signature, and excludes @MethodSource provider methods from "unused declaration" warnings. The gutter run icon lets you execute the entire parameterized suite or rerun only the specific invocations that failed.

Running tests in Maven and Gradle

Both build tools need useJUnitPlatform() (Gradle) or the maven-surefire-plugin configured for JUnit Platform to pick up Jupiter tests. Without this, parameterized tests either skip silently or report as errors rather than test failures.

One JUnit 6 constraint worth knowing up front: Maven Surefire and Failsafe versions older than 3.0.0 are no longer supported. If your pom.xml pins an older plugin version, bump it, or parameterized tests may not execute under the JUnit Platform at all.

Display names

@ParameterizedTest accepts a name attribute. The placeholders {index}, {0}, {1}, and {arguments} let you build readable names from the supplied values:

@ParameterizedTest(name = "[{index}] discount for {0} loyalty years = {1}%") 

The test runner displays something like [3] discount for 5 loyalty years = 10%, which makes failures self-explanatory without opening the test body.

Using @ValueSource

@ValueSource supplies a single array of literals (strings, ints, longs, doubles, or class references) and is the right choice when your test needs one argument per invocation and the values are small enough to inline.

@ParameterizedTest @ValueSource(strings = {"radar", "level", "kayak"}) void isPalindrome(String word) { assertTrue(IsPalindromeChecker.check(word)); } 

When your data grows beyond three or four values, or when you need more than one argument per invocation, move to @CsvSource or @MethodSource.

Using @NullSource, @EmptySource, and @NullAndEmptySource

These three annotations inject edge-case inputs for null and empty states. They're most useful on service methods and validators that need to handle blank input gracefully.

@ParameterizedTest @NullAndEmptySource @ValueSource(strings = {" ", "\t", "\n"}) void rejectsBlankUsername(String username) { assertThrows(InvalidInputException.class, () -> userService.register(username, "valid@example.com")); } 

Combining @NullAndEmptySource with @ValueSource on the same method covers null, empty string, and whitespace-only inputs in one test method. JUnit 6 supports stacking these annotations.

One interop detail matters if the method under test is written in Kotlin: a Kotlin function with a non-nullable parameter (fun register(username: String, ...)) compiles a null-check at the start of the method body. Feed it a literal null from @NullSource, and you get a bare NullPointerException before your own validation logic ever runs — not the InvalidInputException the test expects. Declare the parameter as nullable (username: String?) and check it with username.isNullOrBlank() if you want @NullAndEmptySource to exercise your validation path rather than Kotlin's own null-check.

Using @CsvSource

@CsvSource supplies multiple arguments per invocation as comma-separated rows defined inline. Use it when you need two or three related values, and you want to keep the data close to the test:

@ParameterizedTest(name = "{0} + {1} = {2}") @CsvSource({ "1, 2, 3", "5, 5, 10", "-1, 1, 0", "100, -100, 0" }) void addsCorrectly(int a, int b, int expected) { assertEquals(expected, calculator.add(a, b)); } 

JUnit handles type conversion automatically, so the string "3" becomes the int 3 without any adapter code. For values that contain commas, wrap the field in single quotes, as in "'Smith, John', john@example.com, 30".

Under the hood, JUnit 6 replaced the CSV parser (it now uses FastCSV instead of univocity-parsers), and a few edge cases around quoting changed as a result — extra characters after a closing quote now throw instead of being silently ignored, for example. The common case above, including quoted fields containing commas, parses exactly the same as it did under JUnit 5.

@CsvSource starts to strain when you have more than six or seven rows, when values are long, or when you're reusing the same dataset across multiple test classes. At that point, use @CsvFileSource.

Loading test data with @CsvFileSource

@CsvFileSource reads rows from a file on the classpath:

@ParameterizedTest @CsvFileSource(resources = "/test-data/pricing-scenarios.csv", numLinesToSkip = 1) void calculatesPriceCorrectly(String tier, int loyaltyYears, boolean isVip, String expectedPrice) { // assertions } 

The numLinesToSkip = 1 skips the header row. The file sits under src/test/resources. Reach for this approach when your test dataset is large enough to be maintained separately, when it's shared with non-Java stakeholders (QA, product), or when it's generated by another tool.

Creating dynamic test data with @MethodSource

@MethodSource delegates to a factory method that returns a Stream<Arguments>, a List, or an Iterable. It's the right choice for complex objects, computed values, and any scenario where inline CSV can't express your data structure.

@ParameterizedTest(name = "{index}: {0}") @MethodSource("pricingScenarios") void calculatesPriceForAllTiers(PricingScenario scenario) { DiscountResult result = pricingService.calculate(scenario.input()); assertEquals(scenario.expected(), result); } static Stream<Arguments> pricingScenarios() { return Stream.of( Arguments.of(new PricingScenario( "VIP with 5 loyalty years", new DiscountInput(new BigDecimal("200.00"), 5, true), new DiscountResult(new BigDecimal("150.00"), 25) )), Arguments.of(new PricingScenario( "Standard user, no discount", new DiscountInput(new BigDecimal("100.00"), 0, false), new DiscountResult(new BigDecimal("100.00"), 0) )) ); } 

The factory method must be static by default because JUnit instantiates a new test class per invocation under the default PER_METHOD lifecycle. You can lift that restriction with @TestInstance(TestInstance.Lifecycle.PER_CLASS), which becomes particularly useful in Kotlin (covered in a later section).

Supplying data with a custom ArgumentsProvider

When you want to share a dataset across multiple test classes, or load data from an external source at runtime, implement ArgumentsProvider:

public class DiscountScenariosProvider implements ArgumentsProvider { @Override public Stream<? extends Arguments> provideArguments(ParameterDeclarations parameters, ExtensionContext context) { return Stream.of( Arguments.of(new DiscountInput(new BigDecimal("100.00"), 5, false), new DiscountResult(new BigDecimal("90.00"), 10)), Arguments.of(new DiscountInput(new BigDecimal("200.00"), 0, true), new DiscountResult(new BigDecimal("170.00"), 15)) ); } } 

Then reference it with @ArgumentsSource(DiscountScenariosProvider.class). The provider has access to the ExtensionContext, which means it can read annotations on the test class, inspect the test method name, or pull configuration from the JUnit store. That access is useful when you want a single provider to behave differently across different test contexts.

Note the two-argument provideArguments(ParameterDeclarations, ExtensionContext) signature above. The older single-argument provideArguments(ExtensionContext) still compiles and runs under JUnit 6, but it's deprecated — the compiler will flag it, and new providers should use the two-argument form.

Testing complex objects

Passing plain primitives through @CsvSource works until you need to test against DTOs, entity objects, or configuration objects. At that point, use @MethodSource or a custom ArgumentsProvider and construct your objects directly in the factory method.

DTOs. For a Spring Boot user registration endpoint, a validation test might look like this:

@ParameterizedTest(name = "{index}: {0}") @MethodSource("invalidRegistrationRequests") void rejectsInvalidRequests(String scenario, UserRegistrationRequest request, int expectedViolations) { Set<ConstraintViolation<UserRegistrationRequest>> violations = validator.validate(request); assertEquals(expectedViolations, violations.size(), scenario); } static Stream<Arguments> invalidRegistrationRequests() { return Stream.of( Arguments.of("blank username", new UserRegistrationRequest("", "valid@example.com", 25), 1), Arguments.of("invalid email", new UserRegistrationRequest("alice", "not-an-email", 25), 1), Arguments.of("negative age", new UserRegistrationRequest("bob", "bob@example.com", -1), 1), Arguments.of("blank username and invalid email", new UserRegistrationRequest("", "not-an-email", 25), 2) ); } 

The scenario string argument doubles as a display name fragment and as an assertion message, so a failure tells you exactly which case broke without making you decode an index number.

Entity objects. The same pattern applies directly to JPA entities — construct them in the factory method and assert against their behavior:

@ParameterizedTest(name = "{index}: {0} matches category {1}? {2}") @MethodSource("productsByCategory") void filtersProductsByCategory(Product product, String category, boolean expectedMatch) { assertEquals(expectedMatch, product.getCategory().equals(category)); } static Stream<Arguments> productsByCategory() { return Stream.of( Arguments.of(new Product("Laptop", "ELECTRONICS", new BigDecimal("999.00")), "ELECTRONICS", true), Arguments.of(new Product("Laptop", "ELECTRONICS", new BigDecimal("999.00")), "BOOKS", false) ); } 

Configuration objects. Configuration-bound objects can be constructed directly too, which is useful for testing the logic that consumes them without spinning up a Spring context:

@ParameterizedTest(name = "{index}: enabled={0}, cap={1} -> bonus={2}") @MethodSource("pricingConfigurations") void loyaltyBonusRespectsConfiguration(boolean enabled, int cap, int expectedBonus) { PricingProperties properties = new PricingProperties(enabled, cap); int bonus = properties.isLoyaltyBonusEnabled() ? properties.getLoyaltyBonusCap() : 0; assertEquals(expectedBonus, bonus); } static Stream<Arguments> pricingConfigurations() { return Stream.of( Arguments.of(true, 10, 10), Arguments.of(true, 5, 5), Arguments.of(false, 10, 0) ); } 

This verifies the logic that reads a configuration object in isolation. When you need to verify that Spring actually binds your application.properties values into that object correctly, that's a job for @SpringBootTest or ApplicationContextRunner, covered next.

Parameterized testing in Spring Boot

Combining @ParameterizedTest with Spring test slice annotations gives you data-driven coverage across every layer of a Spring Boot application.

Service layer tests run with plain constructor instantiation or with @SpringBootTest and @Autowired constructor injection:

@SpringBootTest @TestInstance(TestInstance.Lifecycle.PER_CLASS) class PricingServiceTest { @Autowired private PricingService pricingService; @ParameterizedTest @MethodSource("pricingCases") void calculatesPriceCorrectly(DiscountInput input, DiscountResult expected) { assertEquals(expected, pricingService.calculate(input)); } Stream<Arguments> pricingCases() { return Stream.of( Arguments.of(new DiscountInput(BigDecimal.valueOf(100), 5, false), new DiscountResult(BigDecimal.valueOf(90).setScale(2), 10)) ); } } 

Validation tests follow the pattern shown earlier, building a jakarta.validation.Validator directly (no Spring context required) and running each invalid payload through it.

Before wiring up controller or repository tests, there's a dependency change worth knowing about: Spring Boot 4 split its test-support autoconfiguration into per-feature modules. spring-boot-starter-test alone no longer pulls in @WebMvcTest or @DataJpaTest — each needs its own starter:

testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.springframework.boot:spring-boot-starter-data-jpa-test") 

The annotations also moved packages. @WebMvcTest is now org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest, and @DataJpaTest is org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest. Spring Boot's own @MockBean was removed in the same release — use Spring Framework's @MockitoBean (org.springframework.test.context.bean.override.mockito.MockitoBean) in its place.

Controller tests with @WebMvcTest and MockMvc work naturally with @CsvSource or @MethodSource. You build the request body, fire it through mockMvc.perform(), and assert the response status per invocation:

@WebMvcTest(UserController.class) class UserControllerParameterizedTest { @Autowired private MockMvc mockMvc; @MockitoBean private UserService userService; @ParameterizedTest(name = "{index}: {0}/{1} -> {2}") @CsvSource({ "alice, valid@example.com, 201", "'', valid@example.com, 400", "bob, not-an-email, 400" }) void registersUser(String username, String email, int expectedStatus) throws Exception { mockMvc.perform(post("/api/users") .contentType(MediaType.APPLICATION_JSON) .content(""" {"username": "%s", "email": "%s", "age": 30} """.formatted(username, email))) .andExpect(status().is(expectedStatus)); } } 

Repository tests with @DataJpaTest can drive @ParameterizedTest to verify query correctness across different filter combinations (entity status enums, date ranges, or tier codes) without repeating setup for each case:

@DataJpaTest class ProductRepositoryTest { @Autowired private ProductRepository productRepository; @ParameterizedTest(name = "{index}: {0} has {1} product(s)") @MethodSource("categoryExpectations") void countsProductsByCategory(String category, long expectedCount) { productRepository.save(new Product("Laptop", "ELECTRONICS", new BigDecimal("999.00"))); productRepository.save(new Product("Monitor", "ELECTRONICS", new BigDecimal("199.00"))); productRepository.save(new Product("Novel", "BOOKS", new BigDecimal("15.00"))); long count = productRepository.findByCategory(category).size(); assertEquals(expectedCount, count); } static Stream<Arguments> categoryExpectations() { return Stream.of( Arguments.of("ELECTRONICS", 2L), Arguments.of("BOOKS", 1L) ); } } 

Configuration property testing with Spring's ApplicationContextRunner lets you verify that each possible value for a feature flag or config property produces the expected binding, with every scenario named and independently reportable — without the overhead of a full @SpringBootTest:

@ParameterizedTest(name = "{index}: enabled={0}, cap={1} -> bonus={2}") @CsvSource({ "true, 10, 10", "true, 5, 5", "false, 10, 0" }) void loyaltyBonusRespectsConfiguration(boolean enabled, int cap, int expectedBonus) { new ApplicationContextRunner() .withUserConfiguration(PricingPropertiesConfig.class) .withPropertyValues( "pricing.loyalty-bonus-enabled=" + enabled, "pricing.loyalty-bonus-cap=" + cap) .run(context -> { PricingProperties properties = context.getBean(PricingProperties.class); int bonus = properties.isLoyaltyBonusEnabled() ? properties.getLoyaltyBonusCap() : 0; assertEquals(expectedBonus, bonus); }); } 

This combines Spring's test infrastructure with parameterized execution in both directions: the ApplicationContextRunner builds a minimal context per invocation, and @CsvSource drives th...