DEV Community

Cover image for JUnit 6 Parameterized Tests: The Complete Guide to Data-Driven Testing on the JVM
Aniket Bhattacharyea
Aniket Bhattacharyea

Posted on

JUnit 6 Parameterized Tests: The Complete Guide to Data-Driven Testing on the JVM

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()
}
Enter fullscreen mode Exit fullscreen mode

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}%")
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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"));
}
Enter fullscreen mode Exit fullscreen mode

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));
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
        ))
    );
}
Enter fullscreen mode Exit fullscreen mode

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))
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
    );
}
Enter fullscreen mode Exit fullscreen mode

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)
    );
}
Enter fullscreen mode Exit fullscreen mode

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)
    );
}
Enter fullscreen mode Exit fullscreen mode

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))
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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));
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        });
}
Enter fullscreen mode Exit fullscreen mode

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

@TestInstance(TestInstance.Lifecycle.PER_CLASS) is worth applying to Spring integration tests by default, because it allows @MethodSource factory methods to live as instance methods rather than static methods. This is particularly useful in Kotlin, where a companion object with @JvmStatic is the alternative and instance methods are the cleaner option.

Cleaner parameterized tests with Kotlin

Kotlin's interoperability with the JVM means you can call JUnit 6 from Kotlin without any adapters. The annotations work identically. The gain comes from what Kotlin removes from the picture: builder classes, new, verbose static-factory-method requirements, and the noise that accumulates when you construct several complex objects inline.

How to call JUnit 6 from Kotlin

By default, JUnit's @MethodSource requires a static factory method. Kotlin uses a companion object with @JvmStatic for this purpose. You can avoid that boilerplate entirely by annotating the test class with @TestInstance(TestInstance.Lifecycle.PER_CLASS), which lets the factory method be a regular Kotlin instance function:

@SpringBootTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PricingServiceTest @Autowired constructor(
    private val pricingService: PricingService
) {
    fun pricingCases() = listOf(
        Arguments.of(DiscountInput(BigDecimal("100.00"), 5, false),
            DiscountResult(BigDecimal("90.00"), 10)),
        Arguments.of(DiscountInput(BigDecimal("200.00"), 0, true),
            DiscountResult(BigDecimal("170.00"), 15))
    )

    @ParameterizedTest
    @MethodSource("pricingCases")
    fun `calculates price correctly`(input: DiscountInput, expected: DiscountResult) {
        assertEquals(expected, pricingService.calculate(input))
    }
}
Enter fullscreen mode Exit fullscreen mode

Data classes for concise test cases

A common pattern in Kotlin test suites is defining a small data class that bundles the inputs and the expected output for a single scenario. Because data class auto-generates a readable toString(), the test runner displays each invocation with a meaningful description rather than an index number.

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class PricingServiceParameterizedTest {

    private val pricingService = PricingService()

    data class PricingTestCase(
        val description: String,
        val input: DiscountInput,
        val expected: DiscountResult
    )

    private fun pricingScenarios() = listOf(
        PricingTestCase(
            description = "Standard user with no loyalty gets no discount",
            input = DiscountInput(BigDecimal("100.00"), loyaltyYears = 0, isVip = false),
            expected = DiscountResult(BigDecimal("100.00"), 0)
        ),
        PricingTestCase(
            description = "VIP with high loyalty (>=5 yrs) gets 25%",
            input = DiscountInput(BigDecimal("200.00"), loyaltyYears = 6, isVip = true),
            expected = DiscountResult(BigDecimal("150.00"), 25)
        )
    )

    @ParameterizedTest(name = "{index} => {0}")
    @MethodSource("pricingScenarios")
    fun `should calculate discounted price accurately`(testCase: PricingTestCase) {
        val result = pricingService.calculate(testCase.input)
        assertEquals(testCase.expected, result)
    }
}
Enter fullscreen mode Exit fullscreen mode

(The data class above is declared without private — a public @ParameterizedTest method can't expose a parameter type that's private to the class, so if you factor your test case class out, keep its visibility at least as public as the test method that consumes it.)

Named arguments for improved readability

Kotlin named arguments make each test case self-documenting. Compare the Java version of a complex object construction:

// Java
new DiscountInput(new BigDecimal("200.00"), 6, true)
Enter fullscreen mode Exit fullscreen mode

With the Kotlin version:

// Kotlin
DiscountInput(basePrice = BigDecimal("200.00"), loyaltyYears = 6, isVip = true)
Enter fullscreen mode Exit fullscreen mode

At a glance, the Kotlin version tells you what each argument means. Scale this across fifteen test cases and the readability difference compounds.

Cleaner object construction than Java builders

When a Java constructor has more than a couple of same-typed parameters sitting next to each other (two ints, two booleans), teams often reach for a builder to make call sites self-describing:

DiscountInput input = new DiscountInputBuilder()
    .basePrice(new BigDecimal("200.00"))
    .loyaltyYears(6)
    .isVip(true)
    .build();
Enter fullscreen mode Exit fullscreen mode

That builder exists purely to work around Java's lack of named arguments — it's an entire extra class (fields, a fluent setter per field, a build() method) whose only job is call-site clarity. Kotlin's named arguments give you the same clarity directly at the call site, with no builder class to write or maintain:

val input = DiscountInput(basePrice = BigDecimal("200.00"), loyaltyYears = 6, isVip = true)
Enter fullscreen mode Exit fullscreen mode

For test suites specifically, this matters more than it might in production code: a parameterized test's factory method constructs the same object repeatedly with different values, so every parameter it's missing a name for is a small tax paid on every single test case.

Destructuring declarations and extension functions

Destructuring declarations let you unpack a data class in the test method signature when you prefer to work with its fields directly:

data class ValidationCase(
    val description: String,
    val request: UserRegistrationRequest,
    val expectedViolations: Int
)

private fun validationCases() = listOf(
    ValidationCase("valid payload", UserRegistrationRequest("alice", "alice@example.com", 30), 0),
    ValidationCase("blank username", UserRegistrationRequest("", "alice@example.com", 30), 1)
)

@ParameterizedTest(name = "{index}: {0}")
@MethodSource("validationCases")
fun `validates registration requests`(testCase: ValidationCase) {
    val (_, request, expectedViolations) = testCase
    assertEquals(expectedViolations, validator.validate(request).size)
}
Enter fullscreen mode Exit fullscreen mode

Extension functions on the test class give you reusable assertion helpers scoped to the test context, which is a clean way to extract repeated assertion logic without moving it to a utility class:

private fun DiscountResult.assertMatchesExpected(expected: DiscountResult) {
    assertEquals(expected.discountedPrice, this.discountedPrice)
    assertEquals(expected.discountPercent, this.discountPercent)
}
Enter fullscreen mode Exit fullscreen mode

Java vs Kotlin side by side

The same @MethodSource test in Java and Kotlin illustrates where Kotlin cuts boilerplate:

// Java
static Stream<Arguments> pricingCases() {
    return Stream.of(
        Arguments.of(
            new DiscountInput(new BigDecimal("100.00"), 0, false),
            new DiscountResult(new BigDecimal("100.00"), 0)
        ),
        Arguments.of(
            new DiscountInput(new BigDecimal("200.00"), 6, true),
            new DiscountResult(new BigDecimal("150.00"), 25)
        )
    );
}
Enter fullscreen mode Exit fullscreen mode
// Kotlin
fun pricingCases() = listOf(
    Arguments.of(
        DiscountInput(basePrice = BigDecimal("100.00"), loyaltyYears = 0, isVip = false),
        DiscountResult(discountedPrice = BigDecimal("100.00"), discountPercent = 0)
    ),
    Arguments.of(
        DiscountInput(basePrice = BigDecimal("200.00"), loyaltyYears = 6, isVip = true),
        DiscountResult(discountedPrice = BigDecimal("150.00"), discountPercent = 25)
    )
)
Enter fullscreen mode Exit fullscreen mode

The Kotlin version is shorter, each argument is named, and there's no static, new, or explicit Stream.of(). The test assertions stay identical because you're calling the same JUnit 6 API against the same JVM bytecode.

For teams with existing Java Spring Boot services, Kotlin test code compiles to the same bytecode and runs on the same JVM, so you can introduce Kotlin in the test source set (src/test/kotlin) without touching a single production class. The two coexist without conflict, which reflects the strategic partnership between JetBrains and the Spring team, a collaboration aimed at improving the experience for backend developers on both sides of the language boundary. For a real-world account of Kotlin running mission-critical backend systems at scale, see this KotlinConf recap of Verifone's work on Kotlin in payments infrastructure.

Best practices for maintainable parameterized tests

  • Keep datasets focused. A factory method that returns forty rows is probably testing more than one logical concept. Split by concern: one method for valid inputs, one for invalid inputs, and one for edge cases.
  • Prefer descriptive argument names. When your test case object has a description or scenario field, surface it in the display name with {0}. A failure that reads [3] VIP with 5 loyalty years gets 25% discount is actionable; [3] is not.
  • Separate data generation from assertions. The factory method should only construct data, and the assertion logic lives in the test method. This separation makes both easier to read and refactor independently.
  • Use reusable providers. When the same dataset validates both a service and a controller, extract an ArgumentsProvider implementation and reference it from both test classes with @ArgumentsSource. Copying and pasting the factory method creates a maintenance problem.
  • Balance flexibility with readability. A generic @MethodSource that builds objects from a dozen intermediate variables is harder to follow than an explicit list of typed data class instances. Optimize for the next developer reading the failure report, not for line count.
  • Organize shared datasets across modules. In multi-module Gradle or Maven builds, a shared test-fixtures source set (Gradle's java-test-fixtures plugin) lets you publish ArgumentsProvider implementations that multiple modules can depend on.
  • Avoid over-parameterization. If every test in a class is parameterized, the class is probably doing too much. Use parameterized tests for cases where variation in data genuinely changes the outcome. A test that always expects the same result with cosmetically varied inputs isn't verifying anything new.
  • Choose the simplest source that fits the problem — whether @ValueSource for a handful of scalars, @CsvSource for related tuples that fit on one line each, @MethodSource for complex objects, or a custom ArgumentsProvider for shared or dynamically loaded datasets. Reaching for the most powerful option by default makes tests harder to scan than necessary.

JUnit 6's parameterized testing API covers how each source annotation works, why you'd choose one over another, and how the patterns map to real Spring Boot test suites across service, validation, controller, repository, and configuration layers.

The Kotlin patterns covered here (data classes, named arguments, @TestInstance(PER_CLASS), extension functions) reduce test setup code without any production-side changes. For teams already on Spring Boot with Java production code, writing Kotlin in the test source set is a low-risk, incremental adoption path. The two languages share the same classpath, the same JUnit 6 runner, and the same Spring test infrastructure. Your production code stays exactly as it is while your test tables get measurably cleaner, which is the right way to evaluate any addition to a working stack: ask whether it solves a real friction point without demanding a wholesale commitment.

Top comments (0)