Choosing Between JUnit 4 and JUnit 5 Parameterized Tests: An Architecture Note
An architecture note on JUnit parameterized tests: when the design pays off, how JUnit 4's runner differs from JUnit 5's @ParameterizedTest, and how to verify each invocation actually runs.
03 Jul 2025, 20:02 UTC

The problem this design solves
Your test suite has grown into a wall of near-identical methods: testParseEmpty, testParseNull, testParseWhitespace, each differing by one input string. Every new edge case means another copy-pasted method, and nobody is sure whether the negative cases are actually covered. Parameterized tests are the smallest design that fixes this: one test method, many argument sets, with each combination reported as its own result.
The decision that matters is not whether to parameterize but which mechanism fits your codebase, because JUnit 4 and JUnit 5 implement the idea completely differently, and mixing them produces confusing failures.
Requirements
- One logical test exercised against many inputs, with each input reported separately in the build output.
- Argument sources that live close to the test (inline values or CSV) or are computed in code (a method returning a stream of cases).
- Type conversion from strings to the parameter types the test method declares.
- Compatibility with the JUnit version already on the classpath — upgrading just to get parameterized tests is rarely justified on its own.
The smallest suitable design
On JUnit 5 (JUnit Jupiter, 5.x), the design is a single annotation pair. @ParameterizedTest replaces @Test, and a source annotation supplies the arguments:
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PriceParserTest {
@ParameterizedTest
@CsvSource({
"'1,234.50', 1234.50",
"'0.00', 0.00",
"'999999', 999999.00"
})
void parsesFormattedAmounts(String input, double expected) {
assertEquals(expected, PriceParser.parse(input), 0.001);
}
}For anything beyond simple literals, use @MethodSource, which points at a static method returning a Stream of arguments (commonly Arguments.of(...) tuples). This keeps complex object construction in plain Java instead of stringly-typed CSV.
On JUnit 4, the equivalent requires restructuring the whole class: annotate it with @RunWith(Parameterized.class), provide a public static method annotated @Parameterized.Parameters returning a Collection<Object[]>, and store each row in fields (via injection or a constructor). The class needs a public no-arg-compatible setup the runner can drive. That is a heavier design, so on JUnit 4 it pays to reserve parameterization for classes that are genuinely data-driven rather than sprinkling it everywhere.
Trust and data boundaries
Parameterized sources are code, not configuration. Two boundaries deserve attention:
- Type conversion. JUnit 5 converts CSV strings to declared parameter types (numbers, enums, dates) and reports mismatches at execution time with a clear error per invocation. In JUnit 4, the
Object[]rows are assigned positionally; a wrong order or type surfaces as aClassCastExceptionor initialization error that can be harder to trace. Keep the column order in the source visually aligned with the method signature. - Test instance sharing. In JUnit 5, all invocations of one parameterized method share the same test class instance by default (per-method lifecycle). Any mutable field set during one invocation leaks into the next. Keep the test method pure: derive everything from the parameters, or use
@TestInstance(Lifecycle.PER_CLASS)deliberately — or per-invocation setup via@BeforeEach, which does run before each invocation.
Operational checks
Run the class with your normal build command — mvn test or ./gradlew test from the project root, requiring only the permissions your build already has. Then verify two things:
- Invocation count. The test report (surefire XML under
target/surefire-reports, or the Gradle HTML report) should list one result per argument set, not one result total. A count mismatch usually means the source method returned fewer rows than expected. - Failure attribution. Deliberately break one row (temporarily) and confirm the report names that invocation — JUnit 5 shows the arguments in the display name, e.g.
parsesFormattedAmounts(String, double)[2]or a customnamepattern like{index}: {0} -> {1}. If a failure only says "test failed" with no arguments visible, fix the display name before relying on the suite.
Neither check modifies state, so there is nothing to roll back.
Failure modes
- Cross-version imports.
org.junit.jupiter.params.ParameterizedTest(JUnit 5) versusorg.junit.runners.Parameterized(JUnit 4) — importing the wrong one compiles against nothing useful or silently runs zero tests. If a parameterized class reports as "0 tests run," check imports and the runner/engine on the classpath first. - Missing engine. JUnit 5 tests need the Jupiter engine dependency at test runtime; having only the API jar compiles fine but executes nothing.
- Argument count mismatch. A CSV row with three columns feeding a two-parameter method fails that invocation (JUnit 5) or class initialization (JUnit 4). Treat source data with the same review discipline as the test body.
- Over-parameterization. When rows start needing flags like
shouldThrow, the design has outgrown parameterization — split into separate tests.
What would change the design
Stay on the JUnit 4 runner if the module is already JUnit 4-only and the data sets are large and homogeneous. Move to JUnit 5 parameterized tests when you are already on JUnit 5, when you need computed argument sources (@MethodSource), or when per-invocation failure reporting matters to the team. If argument sets grow into files or shared fixtures across classes, that is the point to consider @ArgumentsSource with a custom provider rather than stretching CSV further.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.