# Use JUnit Parameterized Tests Without Matrix Explosion

> A parameterized test that runs 288 times reports its failures as a coordinate rather than a claim, and by default Jupiter throws away any column your method forgot to declare — silently, with the test still passing. This guide covers choosing rows by the rule each one defends rather than by crossing dimensions, switching argument count validation to strict, why a CSV value is quoted in the display name before it is converted, keeping the expected value out of production code, and the three signals that a table should have been two tests.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-09-27T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/junit-parameterized-tests-without-matrix-explosion

- Tags: junit, java, jvm, parameterized-tests, test-design, boundary-testing

**Quick answer:** Jupiter ships no Cartesian-product source, so a matrix is always something you built. Pick rows that sit on a rule's boundary instead of crossing every dimension, turn on `junit.jupiter.params.argumentCountValidation = strict` so a column you forgot to consume raises instead of being discarded, and give every row a name that survives being reordered. Two dimensions that need different setup or different expected values are two parameterized tests, not one wider table.

Here is a rounding test that runs 288 times and cannot tell you which rule broke:

```
@ParameterizedTest
@MethodSource("allCombinations")   // 4 currencies x 8 amounts x 3 modes x 3 signs
void rounding(String currency, String amount, RoundingMode mode, int sign) {
    Money money = Money.of(currency, new BigDecimal(amount).multiply(BigDecimal.valueOf(sign)));
    assertEquals(expected(currency, amount, mode, sign), money.rounded(mode).toString());
}
```

When it fails, the report says `[173] currency = "JPY", amount = "10.5", mode = HALF_EVEN, sign = -1`. Case 173 is a coordinate, not a claim. It does not say which business rule that combination was defending, whether the other 287 rows covered the same rule, or whether `expected(...)`, which is where the real logic went, is right. And the run takes 288 times as long as the one row that mattered.

The problem is not the parameterized-test feature. It is that a product of dimensions was written where a list of rules belonged.

**Turn on strict argument count validation before you audit anything else.** By default a source may supply more arguments than the method declares, and the surplus is dropped without a word. The guide's own phrasing is that this "can lead to bugs where arguments are never passed". A four-column table feeding a three-parameter method has been testing three columns for as long as it has existed, and it has been green the whole time. Verified against the JUnit 6.1.3 user guide on 2026-09-07; the quoting behaviour described later arrived in Jupiter 6.0, so a 5.x suite will render display names differently.

## What a large argument source actually buys

Every invocation of a parameterized test has the full lifecycle of a regular one: "`@BeforeEach` methods will be executed before each invocation", and each invocation appears separately in the tree. That is the cost side. A row is a whole test, priced like a whole test.

What you get for it is a report that names the input. That is genuinely valuable, and it is the only thing a row buys that a loop inside one test method does not. So the question for every row is whether its input, printed in a failure, would tell a reader something the neighbouring rows do not.

Currency rounding has four such inputs and they are not a grid:

```
@ParameterizedTest(name = "{index} {0} {1} at {2} rounds to {3}")
@CsvSource(useHeadersInDisplayName = true, textBlock = """
    CURRENCY, AMOUNT,  MODE,      EXPECTED
    USD,      10.005,  HALF_UP,   10.01
    USD,      10.005,  HALF_EVEN, 10.00
    JPY,      10.5,    HALF_UP,   11
    USD,      -10.005, HALF_UP,   -10.01
    """)
void roundsToTheCurrencysMinorUnit(Currency currency, BigDecimal amount,
                                   RoundingMode mode, BigDecimal expected) {
    assertEquals(0, expected.compareTo(Money.of(currency, amount).rounded(mode)));
}
```

Four rows, four distinct claims. Row one and row two differ only in the mode and prove that the mode is honoured. That is a pair no single row can establish, because 10.005 is the smallest amount where `HALF_UP` and `HALF_EVEN` disagree at two digits. Row three proves the scale comes from the currency rather than from a constant 2: the Java `Currency` API is explicit that its fraction digits are "the same as ISO 4217's minor unit for the currency" and that "the default number of fraction digits for the Euro is 2, while for the Japanese Yen it's 0". Row four proves the sign is handled by rounding away from zero rather than toward it.

Add EUR and you add a row that duplicates row one's claim with a different letter. Add GBP and you add another. That is the test for whether a row belongs: name the rule it defends, and if the name matches an existing row's, delete it.

## Extra columns are thrown away in silence

There is one default in Jupiter's parameterized support that will eventually cost you an afternoon. When a source supplies more arguments than the method declares, the surplus is discarded and the test runs anyway. The guide names the consequence directly: "this can lead to bugs where arguments are never passed to the parameterized class or method."

Which means this compiles, runs, passes, and tests nothing about the mode:

```
// Four columns, three parameters. MODE is silently dropped and every row
// is evaluated with whatever default rounded() uses.
@ParameterizedTest
@CsvSource({ "USD, 10.005, HALF_EVEN, 10.00" })
void rounding(Currency currency, BigDecimal amount, BigDecimal expected) { }
```

Turn it into an error once, in `src/test/resources/junit-platform.properties`:

```
junit.jupiter.params.argumentCountValidation = strict
```

The same setting exists per annotation as `@ParameterizedTest(argumentCountValidation = ArgumentCountValidationMode.STRICT)`, which is the right form while you are migrating a suite that has years of accumulated columns in it. Project-wide is the right form afterwards.

## Pick boundaries, then let the source follow

Choose the values first and the annotation second. Jupiter's sources are not interchangeable, and each one is good at a different kind of set.

`@ValueSource` takes one literal array and can supply only a single argument per invocation, which makes it right for a one-dimensional boundary sweep and wrong for anything else. Its supported types are the primitives plus `String` and `Class`.

`@EnumSource` is the one people under-use. Rather than listing constants by hand, it takes a range: "the range starts from the constant specified in the `from` attribute and includes all subsequent constants up to and including the one specified in the `to` attribute, based on the natural order of the enum constants", and `mode = EXCLUDE` subtracts from that range.

```
// Every rounding mode that can move a half-way value, minus the one whose
// behaviour is asserted separately because it throws.
@ParameterizedTest
@EnumSource(mode = EXCLUDE, names = { "UNNECESSARY" })
void everyModeRoundsAHalfwayValueToTheMinorUnit(RoundingMode mode) {
    assertDoesNotThrow(() -> Money.of(Currency.getInstance("USD"),
            new BigDecimal("10.005")).rounded(mode));
}
```

The value of that over an explicit list is that it stays correct when the enum grows. A new constant joins the run automatically and either passes or tells you it needs a rule.

For null and empty handling, use the dedicated sources rather than smuggling sentinels through CSV. `@NullSource`, `@EmptySource` and the composed `@NullAndEmptySource` are repeatable alongside `@ValueSource`, and the guide's own example, `@NullAndEmptySource` plus four blank strings, produces exactly six invocations rather than a hand-maintained list. Note that `@NullSource` "cannot be used for a parameter that has a primitive type", which is the correct compile-time pressure to declare the parameter as the boxed type when null is a real input.

`@MethodSource` is for rows a literal cannot express: a set built from a domain type, or one that closes a resource. Two constraints decide whether it is safe. The factory "must be static unless the test class is annotated with `@TestInstance(Lifecycle.PER_CLASS)`", and if it returns a `Stream`, "JUnit will properly close it by calling `BaseStream.close()`, making it safe to use a resource such as `Files.lines()`."

What a factory must not do is read a system whose contents can change between runs. A method source that queries a database produces a row count that varies, display names that vary with it, and a green run that proves the fixture was empty. If the cases come from data, snapshot the data into the repository and let the factory read the snapshot.

## The name is what survives a reorder

A row's index is not an identity. Insert a case at the top and every subsequent row's name changes, so a CI history keyed on `[7]` now points at a different claim. Two mechanisms fix that, and both are worth the few characters.

The first is parameter names, which are not in the bytecode by default. The guide is specific: "to ensure that parameter names are present in the bytecode, test code must be compiled with the `-parameters` compiler flag for Java or with the `-java-parameters` compiler flag for Kotlin." Without it you get bare values; with it you get `currency = "JPY"`.

```
tasks.withType(JavaCompile).configureEach {
    options.compilerArgs.add('-parameters')
}
```

The second is naming rows explicitly. `Named` labels one argument and `argumentSet` labels a whole row, and the corresponding placeholders are `{argumentSetName}` and `{argumentSetNameOrArgumentsWithNames}` alongside the more familiar `{index}`, `{arguments}`, `{argumentsWithNames}` and positional `{0}`.

```
static Stream<Arguments> roundingRules() {
    return Stream.of(
        argumentSet("half-up breaks the tie upward",
                usd(), bd("10.005"), HALF_UP,   bd("10.01")),
        argumentSet("half-even breaks the tie to the even digit",
                usd(), bd("10.005"), HALF_EVEN, bd("10.00")),
        argumentSet("a zero-minor-unit currency rounds to whole units",
                jpy(), bd("10.5"),   HALF_UP,   bd("11")),
        argumentSet("a refund rounds away from zero",
                usd(), bd("-10.005"), HALF_UP,  bd("-10.01"))
    );
}
```

Now a failure reads "a zero-minor-unit currency rounds to whole units" and the regression is traceable across a reorder, a rename and a year. Two details will bite while you set this up. The `name` attribute "is a `MessageFormat` pattern. Thus, a single quote (`'`) needs to be represented as a doubled single quote (`''`)". So an apostrophe in a display name silently swallows the text after it. And argument strings in names are truncated at `junit.jupiter.params.displayname.argument.maxlength`, which defaults to 512 characters, so a row identified only by a large JSON payload becomes indistinguishable from its neighbour.

## CSV values are quoted before they are converted

One more thing about display names that surprises everyone the first time. As of Jupiter 6.0, text arguments in parameterized display names are quoted by default: a `CharSequence` is wrapped in double quotes, a `Character` in single quotes, and control characters are escaped, so a tab appears as `"\t"` rather than as invisible whitespace. That is a straightforward improvement. The part to internalise is when it happens:

> The original source arguments are quoted when generating a display name, and this occurs before any implicit or explicit argument conversion is performed.

So a row written `0xF1` and consumed as an `int` reports as `rank = "0xF1"`, not `241`. The display name shows what you typed; the assertion sees what it converted to. When those two differ enough to confuse a reader, put the converted value in the row name yourself with `argumentSet`. The behaviour can be switched off per annotation via `quoteTextArguments = false`, which is occasionally the right call for a suite whose snapshots contain the old unquoted names.

The conversion itself is worth knowing precisely, because it is what lets a CSV row carry domain types instead of strings. Implicit conversion covers the primitives, enums, `BigDecimal`, `java.util.Currency` (`"JPY"` becomes `Currency.getInstance("JPY")`), the `java.time` types, `Path`, `URI` and more. Beyond that list there is a fallback: a target type with "a non-private, static method declared in the target type that accepts either a single `String` argument or a single `CharSequence` argument and returns an instance of the target type" is constructed from the column automatically. Give `Money` a `parse` method and `USD 10.005` becomes a `Money` with no converter to write.

The null rules are the ones to memorise, because they are asymmetric. An unquoted empty column "will always be converted to a null reference regardless of any custom values configured via the `nullValues` attribute". A quoted empty value `''` yields an empty string unless `emptyValue` says otherwise. And "an `ArgumentConversionException` is thrown if the target type of a null reference is a primitive type", which is how a trailing comma in a text block turns into a failure that names conversion rather than the column you actually mistyped.

## Keep the expected value out of the implementation

The original 288-row example had a second defect that the row count hid. `expected(currency, amount, mode, sign)` is a second implementation of rounding, written by the same person, on the same day, with the same misunderstanding available to it. When both agree, the test proves they agree.

A literal in a column cannot do that. Neither can a value derived from a published rule: ISO 4217's minor unit, a rate table from the tax authority, a worked example in a specification. The discipline is small and absolute: the expected column is typed by a human from a source outside the codebase, or the row is not evidence.

The exception worth allowing is a *property* rather than an oracle. "Rounding twice at the same scale equals rounding once" and "the rounded value differs from the input by less than one minor unit" are assertions that need no expected column at all, and they hold across the whole input space. Those belong in their own test, next to the table rather than inside it, because a property that fails tells you something a row cannot.

## When a row should have been its own test

Rows share a body. That is the whole mechanism, and it is also the constraint that decides when a table has been stretched too far. Split when any of three things is true.

The setup differs. If half the rows need a promotion loaded and half must not have one, the `@BeforeEach` ends up branching on a parameter, and a lifecycle method that reads its own test's arguments is a design that will not survive the next case.

The oracle differs in kind. A row that expects a value and a row that expects an exception are not the same test with different data. `assertThrows` and `assertEquals` are different assertions. A nullable expected column with an `if` around it is the smell.

The dimensions are independent. Currency and rounding mode interact, which is why they share a table. Currency and locale do not, so testing all pairs multiplies the run to establish nothing that either dimension alone would miss.

There is a fourth case where the answer is a different annotation entirely. When several test methods in a class all need the same parameter, `@ParameterizedClass` parameterizes the container instead, with the value injected into a `@Parameter` field or the constructor. It is worth knowing about and worth waiting on: the guide marks it experimental, and pinning a suite's structure to an experimental feature is a migration you will pay for twice.

## Apply this now

Set `junit.jupiter.params.argumentCountValidation = strict` and run the suite. Every failure it produces is a column that was never reaching a parameter, and each one is a test that has been asserting less than its source claimed, sometimes for years. Fix them before touching anything else, because until they are fixed you cannot trust the rest of the audit.

Then take the parameterized test in your suite with the most rows and write, beside each row, the one sentence it proves. Rows whose sentences match another row's are deletions. Rows you cannot write a sentence for are the ones that came from a product rather than a rule, and they are usually the majority.

Add `-parameters` to the compiler arguments in the same change, so the rows that survive report their inputs by name. The run should get shorter and the failure output should get longer, which is the trade this whole exercise is making.

## Questions about parameterized test design

### How many rows is too many for one test?

There is no count, but there is a check: if you cannot state what each row proves without looking at the implementation, the table is too big regardless of size. A forty-row table generated from a published rate schedule is fine, because every row traces to a line in the schedule. A twelve-row table built by crossing three flags with four inputs is not, because eight of those rows exist only because the other four did. The same crossing happens one level up when CI runs the suite per JDK and operating system; the [GitHub Actions Matrix Preview](https://automationtester.in/tools/github-actions-matrix-preview) shows the jobs a `strategy.matrix` multiplies into before they run.

### Should boundary values live in the annotation or in a constant?

In the annotation, spelled out. A row reading `BULK_THRESHOLD, 17.00` passes when the constant moves, because both sides moved together. That is the same defect as an oracle that calls production code. Literal boundary values are one of the few places where duplicating a constant is correct, and the duplication is the point: the test is where the rule is written down.

### What about generating rows from a specification file?

Reasonable, with one condition: the file has to be in version control next to the test. `@CsvFileSource` reads either a classpath resource or a path on disk, skips headers via `numLinesToSkip` and treats lines starting with its `commentCharacter`, `#` by default, as comments, so the fixture can explain itself. A file committed alongside the code gives you a diff when the cases change, which is the property a database query cannot offer. Watch one inconsistency while porting rows into it: `@CsvFileSource` uses a double quote as its quote character by default, where `@CsvSource` uses a single quote.

### Do parameterized tests run concurrently with each other?

Invocations are nodes in the test tree like any others, so they follow the same execution mode rules and the same `@Execution` inheritance. The practical consequence is that a table sharing a mutable field across rows becomes a race the moment someone enables concurrency at the class level. Rows that need shared state are rows that should have been separate tests; part three of this series covers how that failure presents.

### Is a loop inside one test ever the better choice?

When the inputs are not individually interesting. Checking that a codec round-trips a thousand random byte arrays does not benefit from a thousand entries in the report. The failure you care about is the first mismatch, and one test with a loop reports it just as well while costing one node instead of a thousand. Reach for a parameterized test when each input has a name; reach for a loop when the input is a sample.

## Primary references

- [Parameterized Classes and Tests — JUnit 6.1.3 user guide](https://docs.junit.org/current/writing-tests/parameterized-classes-and-tests.html). Argument count validation and the warning that surplus arguments are ignored, so "this can lead to bugs where arguments are never passed"; the `junit.jupiter.params.argumentCountValidation` parameter and the `STRICT` annotation attribute. The `@ValueSource` supported types and single-argument limit; `@NullSource`, `@EmptySource` and `@NullAndEmptySource` including that `@NullSource` cannot target a primitive; the `@EnumSource` `from`/`to` range semantics and `mode = EXCLUDE`; the `@MethodSource` static-factory rule and stream closing via `BaseStream.close()`; the `@CsvSource` quoting, trimming and null rules including that an unquoted empty value is always null and that a null bound to a primitive raises `ArgumentConversionException`; `textBlock` with `useHeadersInDisplayName`; the implicit conversion table including `java.util.Currency` and `BigDecimal`, and the fallback String-to-object factory method or constructor; the display-name placeholders, the `MessageFormat` doubled-quote rule, the `-parameters` compiler flag requirement and the 512-character `displayname.argument.maxlength` default; the `Named` API and `argumentSet(String, Object…)`, a static factory on `org.junit.jupiter.params.provider.Arguments`; the `@CsvFileSource` `resources`, `files`, `numLinesToSkip` and `commentCharacter` attributes and its double-quote default; that quoting of text arguments became the default in Jupiter 6.0 and "occurs before any implicit or explicit argument conversion is performed"; that each invocation has the same lifecycle as a regular `@Test`; and that `@ParameterizedClass` is an experimental feature.

- [Java SE — `java.util.Currency`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Currency.html). That `getDefaultFractionDigits()` returns a value where "the number of fraction digits is the same as ISO 4217's minor unit for the currency", and that "the default number of fraction digits for the Euro is 2, while for the Japanese Yen it's 0" — the fact the JPY row in the worked table exists to protect.

- [Parallel Execution — JUnit 6.1.3 user guide](https://docs.junit.org/current/writing-tests/parallel-execution.html). That `@Execution` applies to the annotated element "and its subelements", which is what makes a parameterized test's invocations inherit their container's execution mode.

- [Gradle 9.7.1 — the Test task and useJUnitPlatform()](https://docs.gradle.org/current/userguide/java_testing.html). That `useJUnitPlatform()` is what makes a Gradle `Test` task run Jupiter at all, and that every JVM test in Gradle runs through a `Test` task whose framework and compiler settings you configure — the wiring the `-parameters` snippet slots into. Gradle 9.7.1.

## Continue reading on AutomationTester.in

- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)
- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes)

Source: [Use JUnit Parameterized Tests Without Matrix Explosion](https://automationtester.in/blog/automation-tutorials/junit-parameterized-tests-without-matrix-explosion) by Shashank Rawlani.
