JUnit Jupiter Parameterized Test Extension

This project extends JUnit Jupiter parameterized tests with generated parameter values for numbers, date/time types, strings, booleans, and enums.

It is designed to reduce @MethodSource boilerplate while preserving type safety and adding compile-time validation for supported annotations.

Installation

Runtime Extension

Gradle:

dependencies {
testImplementation("com.wesleyhome.test:junit-jupiter-params-generated:<latestVersion>")
}

Maven:

<dependency>
<groupId>com.wesleyhome.test</groupId>
<artifactId>junit-jupiter-params-generated</artifactId>
<version>${latestVersion}</version>
<scope>test</scope>
</dependency>

Annotation Processor (Optional, recommended)

Gradle (KSP):

plugins {
id("com.google.devtools.ksp") version "<kspVersion>"
}

dependencies {
ksp("com.wesleyhome.test:annotation-processor:<latestVersion>")
}

Maven:

<dependency>
<groupId>com.wesleyhome.test</groupId>
<artifactId>annotation-processor</artifactId>
<version>${latestVersion}</version>
<scope>provided</scope>
</dependency>

Compatibility Matrix

Component Supported Verified against
Java 17+ 17 (class file major version 61)
Kotlin/JVM 2.4+ 2.4.20
JUnit Jupiter 6.0.3+ 6.1.3, with the full suite re-run against 6.0.3
Gradle any version supporting the above 9.7.1
Maven test scope for the extension -

Kotlin 2.4 is a floor rather than a preference: the published classes carry Kotlin metadata v2.4, and earlier compilers refuse to read it. Consuming this library from a Kotlin 2.3 build fails with "compiled with an incompatible version of Kotlin".

The optional annotation processor runs under KSP, so compile-time validation applies to Kotlin sources only. Java sources get the full runtime behaviour, but misconfigured annotations surface when the test runs rather than when it compiles.

Getting Started

import com.wesleyhome.test.jupiter.annotations.GeneratedParametersTest
import com.wesleyhome.test.jupiter.annotations.number.IntRangeSource
import kotlin.test.assertTrue

@GeneratedParametersTest
fun testWithGeneratedParameters(@IntRangeSource(min = 1, max = 10) value: Int) {
assertTrue(value in 1..10)
}

Core Sources

  • Numeric ranges: @IntRangeSource, @LongRangeSource, @DoubleRangeSource, @FloatRangeSource

  • Numeric explicit values: @IntSource, @LongSource, @DoubleSource, @FloatSource

  • Date/time ranges: @InstantRangeSource, @LocalDateRangeSource, @LocalDateTimeRangeSource, @LocalTimeRangeSource

  • Date/time explicit values: @InstantSource, @LocalDateSource, @LocalDateTimeSource, @LocalTimeSource

  • Random date/time: @RandomInstantSource

  • Other types: @StringSource, enums, booleans

Parameter Combinations

When multiple parameters are generated, the extension uses a Cartesian product.

Invocation count:

totalInvocations = product(parameterOptionCounts)

Example:

@GeneratedParametersTest
fun combinations(
@IntRangeSource(min = 1, max = 3) first: Int, // 3 values
@IntRangeSource(min = 10, max = 12) second: Int // 3 values
) {
// Runs 9 times (3 * 3)
}

CI warning: two larger ranges can multiply quickly (for example, 100 x 100 = 10,000 invocations).

A product above one million invocations is refused before any test runs, with a message naming each parameter and its option count. Raise or lower that with a configuration parameter:

# junit-platform.properties
com.wesleyhome.test.jupiter.max.permutations=5000000

Filtering Combinations

A Cartesian product generates combinations that are invalid, not merely uninteresting - a start after its end, a currency that does not exist in the selected country. Skipping those in the test body with Assumptions.assumeTrue still generates, schedules and runs them, and reports them as skipped. A filter removes them before an invocation exists.

Name a method after the test and it applies automatically:

@GeneratedParametersTest
fun ordered(
@IntRangeSource(min = 1, max = 3) start: Int,
@IntRangeSource(min = 1, max = 3) end: Int
) {
// runs 6 times, not 9
}

companion object {
@JvmStatic
fun ordered_filter(start: Int, end: Int): Boolean = start <= end
}

A filter declares only the parameters it is about, matched by name, so several small rules compose instead of one predicate that has to know about everything. A combination runs only if every filter accepts it. Use a suffix when a test has more than one:

@JvmStatic fun prices_filter_ordered(start: Int, end: Int): Boolean = start <= end
@JvmStatic fun prices_filter_currency(currency: Currency, country: Country): Boolean =
currency in country.currencies

Name filters explicitly to share a rule between tests. Explicit and conventional filters both apply:

@GeneratedParametersTest(filters = ["startBeforeEnd"])
fun prices(...)

Rules

  • A filter must be static, or declared in a companion object. Filtering happens before the test instance exists, which is the same constraint @MethodSource has. Filters may live in a superclass.

  • Parameter types must match the generated parameter. A supertype is fine - Number accepts a generated Int - but a filter declaring Long against a generated Int is rejected rather than silently widened by reflection.

  • A filter must return Boolean and must not return null.

  • If every combination is rejected, the test fails and the message names the filters responsible.

  • Filtering reduces what runs, not what is generated, so the invocation ceiling still applies to the full product and generated.invocations still reports it.

The annotation processor warns about any *_filter method that matches no test in its class, so renaming a test cannot silently leave its rule unused.

Invocation Display Names

Each invocation is named the way @ParameterizedTest names its own, so IDEs and test reports read generated invocations the same way:

[1] value=1
[2] value=2

Override the pattern with name:

@GeneratedParametersTest(name = "{index}: {0} squared is {1}")
fun squares(
@IntRangeSource(min = 1, max = 3) value: Int,
@IntRangeSource(min = 1, max = 9) square: Int
) {
}
Placeholder Renders
{index} the 1-based invocation number
{arguments} the generated values, comma separated
{argumentsWithNames} the generated values as name=value, comma separated
{displayName} the test method's own display name
{0}, {1}, ... a single generated value, by position

Only generated parameters are listed. A parameter resolved by another extension - TestInfo, @TempDir, an injected mock - is not named and does not shift the {0}, {1} positions.

Parameter Names

{argumentsWithNames} needs the declared parameter names to be present in the compiled class file. Kotlin records them in its own metadata, so Kotlin test sources need nothing. Java test sources must be compiled with -parameters, or the names fall back to arg0, arg1:

// Gradle
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.add("-parameters")
}
<!-- Maven -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<parameters>true</parameters>
</configuration>
</plugin>

Report Entries

Each test method publishes a summary of what it generated, which is what answers "why does this test have four thousand cases?" from the report rather than by reading the annotations:

generated.invocations = 4320
generated.parameters = min=8 x max=6 x increment=5 x dateFormat=6 x ascending=3

The generated values themselves are in the display name. They can also be published as structured entries, one per invocation, for when the display name is truncated by a report viewer or when something downstream needs to read the values back:

# junit-platform.properties
com.wesleyhome.test.jupiter.report.values=true
generated.left  = 501
generated.right = 1

That is off by default because a ten thousand invocation product means ten thousand entries. The summary is on by default and costs one entry per test method; turn it off with com.wesleyhome.test.jupiter.report.summary=false.

Null Values

Add @WithNull to generate a null case alongside a parameter's other values:

@GeneratedParametersTest
fun handlesMissingName(@StringSource(["ada", "grace"]) @WithNull name: String?) {
// runs with "ada", "grace", null
}
@GeneratedParametersTest
void handlesMissingName(@StringSource(values = {"ada", "grace"}) @WithNull String name) {
}

The null is generated last, after the parameter's other values.

A Kotlin parameter declared nullable also gets a null case today, inferred from its type. That inference is Kotlin-only - a Java Integer is nullable and never receives one - and it says nothing at the use site about what the test will run. It still works, but the annotation processor warns where it is relied on, and it will be removed in a future release. Prefer @WithNull.

@WithNull on a type that cannot hold null, such as a Kotlin Int or a Java int, is an error.

Generated vs Random Values

  • Range and explicit-value sources are deterministic by definition.

  • @RandomInstantSource draws from a fixed seed, so the same bounds produce the same sequence.

  • Invocation display names include resolved argument values, so failures show the generated value directly.

Change the seed to explore different values, deliberately rather than by accident:

@GeneratedParametersTest
fun expiry(@RandomInstantSource(min = "2024-01-01T00:00:00Z", max = "2025-01-01T00:00:00Z", size = 10, seed = 99L) at: Instant)

Offset bounds need a fixed clock

A fixed seed only makes a sequence reproducible if the range it draws from is also fixed. With useOffset = true the bounds are resolved against the current instant, so by default they move with the wall clock and a rerun tomorrow draws from a different window.

Fix the clock to make those reproducible:

# junit-platform.properties
com.wesleyhome.test.jupiter.clock.fixed.at=2024-01-01T00:00:00Z
com.wesleyhome.test.jupiter.clock.zone=UTC

or set one for a class or a run from an extension:

GeneratedParametersClock.set(extensionContext, Clock.fixed(instant, ZoneOffset.UTC))

Absolute bounds never depended on the clock and are unaffected by either.

Performance and Memory

Generation model:

  • Parameter value lists are generated eagerly per parameter.

  • Test invocations are iterated lazily across the Cartesian product.

Practical guidance:

  • Keep ranges intentional.

  • Prefer smaller random size values.

  • Split high-cardinality tests into targeted suites.

Why Not @MethodSource?

Concern @MethodSource Generated Sources
Boilerplate Requires separate provider methods Inlined annotation configuration
Type safety Depends on method return wiring Annotation-driven parameter typing
Range/date convenience Manual list/stream construction Built-in numeric and date/time sources
Compile-time validation Limited by default Supported via optional annotation processor

@CsvSource vs Generated Parameters

@CsvSource for a combinatorial check requires manually listing every pair:

@ParameterizedTest
@CsvSource({
"1,10",
"1,20",
"1,30",
"2,10",
"2,20",
"2,30",
"3,10",
"3,20",
"3,30"
})
void multipliesAllPairs(int left, int right) {
assertTrue((left * right) > 0);
}

Equivalent generated-parameter test (same 3 x 3 Cartesian product, no manual tuple list):

@GeneratedParametersTest
void multipliesAllPairs(
@IntRangeSource(min = 1, max = 3) int left,
@IntRangeSource(min = 10, max = 30, increment = 10) int right
) {
assertTrue((left * right) > 0);
}

Annotation Processor

The optional annotation processor validates supported annotation configurations at compile time.

Example (compile-time error when min > max):

@GeneratedParametersTest
void invalid(@IntRangeSource(min = 10, max = 1) int value) {
}

Data Flow

[Test Method] -> [Parameter Scan] -> [Per-Parameter Data Generation] -> [Invocation Iteration] -> [Test Execution]

Custom Annotations and Providers

Define a parameter annotation, point it at a provider with @SourceProvider, and implement the provider. AbstractAnnotatedParameterDataProvider<T, A> recovers both the parameter type T and the annotation type A from the type arguments you supply, so it decides on its own which parameters to claim:

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@SourceProvider(CustomSourceDataProvider::class)
annotation class CustomSource(val min: Int, val max: Int, val increment: Int)

class CustomSourceDataProvider : AbstractAnnotatedParameterDataProvider<Int, CustomSource>() {
override fun createParameterOptionsData(testParameter: TestParameter): List<Int> {
val annotation = findAnnotation(testParameter)!!
return (annotation.min..annotation.max step annotation.increment).toList()
}
}

Intermediate classes of your own between the provider and the library base are supported, so shared behaviour across several providers can live in a base class you control.

For a provider that claims a parameter by type alone, without an annotation, extend AbstractParameterDataProvider<T> instead and override only createParameterOptionsData.

To decide for yourself which parameters to claim, implement ParameterDataProvider<T> directly and supply both members:

class CustomSourceDataProvider : ParameterDataProvider<Int> {
override fun providesDataFor(testParameter: TestParameter): Boolean =
testParameter.annotations.any { it is CustomSource }

override fun createParameterOptionsData(testParameter: TestParameter): List<Int> {
val annotation = testParameter.annotations.filterIsInstance<CustomSource>().first()
return (annotation.min..annotation.max step annotation.increment).toList()
}
}

Testing

Run all tests:

./gradlew test

All modules:

Link copied to clipboard