Parameterized & Nested Tests in JUnit 5
Stop copy-pasting near-identical test methods: drive one test with many inputs and group related cases into readable, nested behavior blocks.
Most real test suites accumulate clusters of methods that differ only by their input data: `addsTwoPositives`, `addsTwoNegatives`, `addsZero`. JUnit 5 lets you collapse that duplication with `@ParameterizedTest`, a test that runs once per supplied argument set. Instead of `@Test` you annotate the method with `@ParameterizedTest`, declare parameters in the signature, and attach a source annotation that feeds values. Each invocation becomes its own reported test with its own pass/fail status, so a single failing input never hides behind a green sibling. Remember to add the `org.junit.jupiter:junit-jupiter-params` dependency, which ships with the standard `junit-jupiter` aggregator that Spring Boot's starter already pulls in.
The simplest source is `@ValueSource`, which supplies a flat array of literals of one type: `strings`, `ints`, `longs`, `doubles`, and so on. It is perfect for property-style checks such as 'every value in this list is blank' or 'these numbers are all prime'. When a single parameter is not enough, `@CsvSource` lets you express rows of comma-separated values that JUnit maps positionally onto multiple parameters and even coerces into the declared types, including enums. Use the optional `value`-with-quotes syntax or `textBlock` for readability, set `nullValues` to model nulls, and treat each line as one concrete scenario: input columns first, expected result last.
For data that cannot be written as compile-time literals, reach for `@MethodSource`. It points at a static (or `@JvmStatic` in Kotlin) factory method returning a `Stream`, `Iterable`, or array of arguments; when a row carries more than one value you wrap them with `Arguments.of(...)`. This is the idiomatic place to build domain objects, share fixtures across several parameterized tests, or generate cases programmatically. Because Kotlin companion-object members are not truly static, you must annotate the provider with `@JvmStatic` (and typically put it in a `companion object`) so JUnit's reflection can locate it. `@MethodSource` with an empty string also works when the provider name matches the test method name.
A frequently overlooked detail is reporting. By default a parameterized invocation is labeled with its index and arguments, but you can override this with the `name` attribute using placeholders like `{index}`, `{0}`, `{1}`, or the friendly `{argumentsWithNames}`. Good names turn a wall of `[1]`, `[2]`, `[3]` into self-documenting output such as `add(2, 3) == 5`, which pays off enormously when a build fails in CI and you are reading logs rather than stepping through a debugger. Treat the display name as part of the test's documentation.
Where parameterization removes horizontal duplication, `@Nested` removes vertical disorganization. A `@Nested` inner class groups tests that share a context โ 'when the cart is empty', 'when the user is unauthenticated' โ and lets you give that context a human name with `@DisplayName`. Crucially, lifecycle methods compose: an outer `@BeforeEach` runs before every test in every nested class, then the nested class's own `@BeforeEach` runs, so you can set up the broad fixture once and layer scenario-specific state inside each group. The result reads like a specification, with indentation in the test tree mirroring the structure of the behavior.
In Kotlin, `@Nested` classes must be plain inner classes, declared with the `inner` keyword so they can access the enclosing instance's fields. This is the one place Kotlin developers stumble, because a normal nested class in Kotlin is static-like and JUnit will refuse to run it as a nested test or fail to share outer state. Pair `inner class` with descriptive `@DisplayName` annotations and you get arrange-once, branch-many test classes that stay readable as the behavior under test grows more conditional.
Parameterization and nesting combine naturally: put a `@ParameterizedTest` inside a `@Nested` group to express 'in this state, all of these inputs behave the same way'. For example, a 'when discount is active' nested class can run one parameterized test across many price points, while a sibling 'when discount is expired' class runs the same inputs with different expectations. This keeps the matrix of state-times-input explicit and small, instead of exploding into dozens of bespoke methods. As a rule of thumb: use `@Nested` for distinct *contexts* and `@ParameterizedTest` for distinct *data within a context*.
These features are framework-agnostic and work identically in a Spring Boot test. You can annotate a `@SpringBootTest` or `@WebMvcTest` class method with `@ParameterizedTest`, or place nested classes inside an integration test to group, say, 'authorized requests' and 'forbidden requests'. The mechanics shown here run on plain JUnit Jupiter with no Spring context, which makes them ideal for fast, pure unit tests of your domain logic โ reserve the heavier Spring machinery for the cases that genuinely need a container.
import org.junit.jupiter.params.ParameterizedTestimport org.junit.jupiter.params.provider.CsvSourceimport org.junit.jupiter.params.provider.ValueSourceimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.api.Assertions.assertTrueclass StringCalculatorTest {@ParameterizedTest(name = "\"{0}\" is blank")@ValueSource(strings = ["", " ", "\t", "\n"])fun `detects blank strings`(input: String) {assertTrue(input.isBlank())}// Columns: a, b, expected -> add(a, b) == expected@ParameterizedTest(name = "add({0}, {1}) == {2}")@CsvSource("2, 3, 5", "0, 0, 0", "-1, 1, 0", "100, -50, 50")fun `adds two integers`(a: Int, b: Int, expected: Int) {assertEquals(expected, a + b)}}
@ValueSource and @CsvSource: one method, many inputs. @CsvSource maps columns positionally onto the parameters and coerces types (including the String -> Int conversion here). The name attribute produces readable output.
import org.junit.jupiter.params.ParameterizedTestimport org.junit.jupiter.params.provider.Argumentsimport org.junit.jupiter.params.provider.MethodSourceimport org.junit.jupiter.api.Assertions.assertEqualsimport java.util.stream.Streamdata class Order(val items: Int, val unitPrice: Int)fun totalFor(order: Order): Int = order.items * order.unitPriceclass OrderPricingTest {@ParameterizedTest(name = "{0} -> total {1}")@MethodSource("orderCases")fun `computes order total`(order: Order, expectedTotal: Int) {assertEquals(expectedTotal, totalFor(order))}companion object {@JvmStaticfun orderCases(): Stream<Arguments> = Stream.of(Arguments.of(Order(items = 0, unitPrice = 10), 0),Arguments.of(Order(items = 3, unitPrice = 10), 30),Arguments.of(Order(items = 2, unitPrice = 99), 198),)}}
@MethodSource builds richer cases as domain objects. In Kotlin the provider must live in a companion object and be marked @JvmStatic so JUnit's reflection can call it as a static method. Arguments.of(...) packs multiple values per row.
import org.junit.jupiter.api.BeforeEachimport org.junit.jupiter.api.DisplayNameimport org.junit.jupiter.api.Nestedimport org.junit.jupiter.api.Testimport org.junit.jupiter.api.Assertions.assertEqualsimport org.junit.jupiter.params.ParameterizedTestimport org.junit.jupiter.params.provider.ValueSourceclass ShoppingCart {private val lines = mutableListOf<Int>()fun add(price: Int) { lines += price }fun total(): Int = lines.sum()val size: Int get() = lines.size}@DisplayName("ShoppingCart")class ShoppingCartTest {private lateinit var cart: ShoppingCart@BeforeEachfun createCart() { cart = ShoppingCart() }@Nested@DisplayName("when empty")inner class WhenEmpty {@Testfun `total is zero`() = assertEquals(0, cart.total())}@Nested@DisplayName("when items are added")inner class WhenItemsAdded {@BeforeEachfun seed() { cart.add(10); cart.add(20) }@Testfun `total sums all lines`() = assertEquals(30, cart.total())@ParameterizedTest(name = "adding {0} grows size to 3")@ValueSource(ints = [5, 0, 99])fun `adding keeps counting`(price: Int) {cart.add(price)assertEquals(3, cart.size)}}}
@Nested groups tests by context. Note Kotlin requires the 'inner' keyword so nested classes can read outer state. Lifecycle composes: the outer @BeforeEach runs first, then the nested one. A @ParameterizedTest can live inside a @Nested group.
๐ง Check your understanding
0/1 ยท 0/1 answered1. In Kotlin, a JUnit 5 @MethodSource provider method that supplies arguments must be: