Parameterized & Dynamic Tests
Parameterized & Dynamic Tests
Writing the same test logic multiple times with different inputs is a maintenance trap. JUnit 5 solves this cleanly with @ParameterizedTest, which runs a single test method once per input row. For cases where the test set itself must be computed at runtime, @TestFactory generates DynamicTest instances on the fly. Together these two mechanisms eliminate copy-paste tests and make edge-case coverage explicit and systematic.
Why Parameterization Matters
Consider a validator that must accept a variety of valid inputs and reject an equally diverse set of invalid ones. Without parameterization you write one test per case — six inputs become six near-identical test methods. With @ParameterizedTest you express those six cases in a data table and keep a single assertion body. The benefits are concrete:
- One bug fix in the assertion logic fixes every case at once.
- Adding a new edge case is a one-liner in the data source, not a new method.
- Test reports show each argument set individually, so failures are pinpointed immediately.
@ParameterizedTest lives in junit-jupiter-params. If you use the junit-jupiter aggregate BOM this is included automatically; otherwise add it explicitly in your build file.
@ValueSource — Inline Scalar Arguments
@ValueSource is the simplest source. You list literals of a single type and JUnit feeds each one to the test method in turn. Supported types include int, long, double, String, Class, and more.
The name attribute on @ParameterizedTest customises the display name. {0} is replaced by the first argument, {displayName} by the method name. Well-named tests make CI output readable without opening the source file.
@MethodSource — Arguments from a Factory Method
@MethodSource calls a static method that returns a Stream (or Iterable, Iterator, or array) of arguments. This is the right choice when arguments are complex objects or require non-trivial construction.
When the source method has the same name as the test method you can omit the string argument: @MethodSource with no value automatically looks for a same-named static method. For cross-class reuse, fully qualify with "com.example.Providers#discountScenarios".
@MethodSource factory runs before the test class is instantiated. It must not depend on instance state, Spring context, or external I/O. If you need database-seeded arguments, reach for @MethodSource calling a test-scoped helper that reads from an in-memory data structure prepared in a @BeforeAll.
@CsvSource and @CsvFileSource — Tabular Data
@CsvSource expresses multi-column rows as quoted strings, keeping the data close to the test without a separate factory method. JUnit converts each column to the method parameter type automatically.
Single quotes inside a CSV value delimit embedded strings containing commas or spaces. An empty value is written as '' and converted to an empty String (not null). For larger datasets, move the data to a file and use @CsvFileSource(resources = "/test-data/truncate.csv") — JUnit reads it from the test classpath.
String, Enum, and types with a single-String constructor or static valueOf. For domain objects (e.g. Money, UserId), use @MethodSource and construct them explicitly in the factory. Trying to coerce arbitrary objects via CSV leads to obscure errors.
Combining NullAndEmpty, EnumSource, and Other Sources
JUnit 5 ships several more built-in sources worth knowing:
@NullSource/@EmptySource— injectnullor an empty value (empty string, list, array). Combine with@NullAndEmptySourcefor defensive null/empty checks.@EnumSource— iterates enum constants, with optionalnamesandmode(INCLUDE / EXCLUDE / MATCH_ALL / MATCH_ANY) for fine-grained selection.
Dynamic Tests with @TestFactory
Sometimes the test set cannot be expressed statically — it depends on data discovered at runtime (e.g., files in a directory, rows from an in-memory repository, entries parsed from a config). @TestFactory returns a Stream<DynamicTest> or any Iterable of DynamicNode. Each DynamicTest has a display name and an Executable (a lambda).
Unlike @ParameterizedTest, @TestFactory methods are not annotated with @Test and they may produce zero tests (the stream can be empty). This makes them suitable for discovery-driven scenarios where "no items found" is a valid outcome rather than a failure.
@ParameterizedTest is simpler, has better IDE support, and its argument sources are visible in version control. Reserve @TestFactory for genuinely dynamic cases — file system probing, API contract verification across a list of endpoints loaded from a config, or exhaustive property-based style checks over a computed input space.
Professional Best Practices
- Name your parameterized tests. Always set a descriptive
nameattribute.name = "[{index}] input={0}"is a minimum; favour domain-meaningful names like"price={0}, tier={1}". - Cover boundary and invalid inputs explicitly. Parameterization lowers the cost of adding cases — use that cheapness to test boundaries, empty inputs, max values, and known historical bugs.
- Keep each row independent. A parameterized test should not depend on execution order. Each invocation is a separate test in JUnit's model.
- Avoid combinatorial explosion. If you have five parameters with ten values each, 10^5 combinations produce noise, not signal. Test meaningful combinations, not the Cartesian product.
Summary
@ParameterizedTest with @ValueSource, @MethodSource, and @CsvSource transforms repetitive test methods into clean, data-driven tables. @TestFactory handles the remaining cases where inputs are unknown until runtime. Together they let you raise coverage without raising maintenance cost — which is the core promise of a good test suite.