Testing with JUnit 5 & Mockito

Parameterized & Dynamic Tests

15 min Lesson 5 of 13

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.
Dependency: @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.

import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import static org.junit.jupiter.api.Assertions.assertTrue; class PalindromeCheckerTest { @ParameterizedTest(name = "ispalindrome({0})") @ValueSource(strings = {"racecar", "level", "madam", "deed"}) void validPalindromes(String word) { assertTrue(PalindromeChecker.check(word)); } @ParameterizedTest @ValueSource(ints = {-5, 0, 1, 100, Integer.MAX_VALUE}) void absoluteValueIsNonNegative(int n) { assertTrue(Math.abs(n) >= 0); } }

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.

import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import java.util.stream.Stream; import static org.junit.jupiter.params.provider.Arguments.arguments; import static org.junit.jupiter.api.Assertions.assertEquals; class DiscountCalculatorTest { // Arguments: price, customerTier, expectedFinalPrice static Stream<Arguments> discountScenarios() { return Stream.of( arguments(100.0, "GOLD", 80.0), arguments(100.0, "SILVER", 90.0), arguments(100.0, "REGULAR", 100.0), arguments(200.0, "GOLD", 160.0) ); } @ParameterizedTest(name = "price={0}, tier={1} -> {2}") @MethodSource("discountScenarios") void appliesCorrectDiscount(double price, String tier, double expected) { DiscountCalculator calc = new DiscountCalculator(); assertEquals(expected, calc.apply(price, tier), 0.001); } }

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".

Keep factory methods pure. A @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.

import org.junit.jupiter.params.provider.CsvSource; import static org.junit.jupiter.api.Assertions.assertEquals; class StringUtilsTest { @ParameterizedTest(name = "truncate({0}, {1}) = {2}") @CsvSource({ "Hello World, 5, Hello", "Hi, 10, Hi", "'', 5, ''", "Testing, 7, Testing" }) void truncatesCorrectly(String input, int maxLen, String expected) { assertEquals(expected, StringUtils.truncate(input, maxLen)); } }

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.

Type coercion limitations. JUnit 5 coerces CSV columns to primitives, 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 — inject null or an empty value (empty string, list, array). Combine with @NullAndEmptySource for defensive null/empty checks.
  • @EnumSource — iterates enum constants, with optional names and mode (INCLUDE / EXCLUDE / MATCH_ALL / MATCH_ANY) for fine-grained selection.
import org.junit.jupiter.params.provider.EnumSource; import static org.junit.jupiter.api.Assertions.assertNotNull; enum Status { PENDING, ACTIVE, SUSPENDED, CLOSED } @ParameterizedTest @EnumSource(value = Status.class, names = {"ACTIVE", "SUSPENDED"}) void activeAndSuspendedHaveDescription(Status s) { assertNotNull(StatusDescriptionRegistry.get(s)); }

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).

import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.TestFactory; import java.util.stream.Stream; import static org.junit.jupiter.api.DynamicTest.dynamicTest; import static org.junit.jupiter.api.Assertions.assertTrue; class PrimeCheckerDynamicTest { private static final int[] KNOWN_PRIMES = {2, 3, 5, 7, 11, 13, 17, 19}; @TestFactory Stream<DynamicTest> knownPrimesAreDetected() { return Stream.of(KNOWN_PRIMES) .map(n -> dynamicTest( "isPrime(" + n + ")", () -> assertTrue(PrimeChecker.isPrime(n)) )); } }

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.

Prefer @ParameterizedTest for fixed data, @TestFactory for runtime-discovered data. @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 name attribute. 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.