Tests parametrizados y anidados en JUnit 5
Deja de copiar y pegar métodos de test casi idénticos: alimenta un solo test con muchas entradas y agrupa casos relacionados en bloques de comportamiento anidados y legibles.
La mayorÃa de las suites de tests reales acumulan grupos de métodos que solo difieren en sus datos de entrada: `sumaDosPositivos`, `sumaDosNegativos`, `sumaCero`. JUnit 5 te permite eliminar esa duplicación con `@ParameterizedTest`, un test que se ejecuta una vez por cada conjunto de argumentos suministrado. En lugar de `@Test`, anotas el método con `@ParameterizedTest`, declaras parámetros en la firma y añades una anotación de fuente que aporta los valores. Cada invocación se convierte en su propio test reportado con su propio estado de éxito o fallo, de modo que una entrada que falla nunca se oculta tras una hermana en verde. Recuerda añadir la dependencia `org.junit.jupiter:junit-jupiter-params`, incluida en el agregador estándar `junit-jupiter` que el starter de Spring Boot ya trae.
La fuente más simple es `@ValueSource`, que suministra un array plano de literales de un solo tipo: `strings`, `ints`, `longs`, `doubles`, etc. Es ideal para comprobaciones de propiedad como 'todos los valores de esta lista están en blanco' o 'estos números son todos primos'. Cuando un único parámetro no basta, `@CsvSource` te permite expresar filas de valores separados por comas que JUnit mapea posicionalmente sobre varios parámetros e incluso convierte a los tipos declarados, incluidos enums. Usa la sintaxis con comillas en `value` o `textBlock` para mayor legibilidad, define `nullValues` para modelar nulos, y trata cada lÃnea como un escenario concreto: primero las columnas de entrada, al final el resultado esperado.
Para datos que no se pueden escribir como literales en tiempo de compilación, recurre a `@MethodSource`. Apunta a un método fábrica estático (o `@JvmStatic` en Kotlin) que devuelve un `Stream`, `Iterable` o array de argumentos; cuando una fila lleva más de un valor los envuelves con `Arguments.of(...)`. Este es el lugar idiomático para construir objetos de dominio, compartir fixtures entre varios tests parametrizados o generar casos de forma programática. Como los miembros de un companion object de Kotlin no son verdaderamente estáticos, debes anotar el proveedor con `@JvmStatic` (y normalmente ponerlo en un `companion object`) para que la reflexión de JUnit pueda localizarlo. `@MethodSource` con cadena vacÃa también funciona cuando el nombre del proveedor coincide con el del método de test.
Un detalle que se suele pasar por alto es el reporte. Por defecto una invocación parametrizada se etiqueta con su Ãndice y argumentos, pero puedes sobrescribirlo con el atributo `name` usando marcadores como `{index}`, `{0}`, `{1}` o el amigable `{argumentsWithNames}`. Buenos nombres convierten un muro de `[1]`, `[2]`, `[3]` en una salida autodocumentada como `add(2, 3) == 5`, lo que compensa enormemente cuando una build falla en CI y estás leyendo logs en lugar de depurando paso a paso. Trata el nombre visible como parte de la documentación del test.
Donde la parametrización elimina la duplicación horizontal, `@Nested` elimina el desorden vertical. Una clase interna `@Nested` agrupa tests que comparten un contexto — 'cuando el carrito está vacÃo', 'cuando el usuario no está autenticado' — y te permite darle un nombre humano con `@DisplayName`. Lo crucial es que los métodos de ciclo de vida se componen: un `@BeforeEach` externo se ejecuta antes de cada test de cada clase anidada, y luego se ejecuta el `@BeforeEach` propio de la clase anidada, de modo que puedes preparar el fixture general una vez y superponer estado especÃfico dentro de cada grupo. El resultado se lee como una especificación, con la indentación del árbol de tests reflejando la estructura del comportamiento.
En Kotlin, las clases `@Nested` deben ser clases internas normales, declaradas con la palabra clave `inner` para que puedan acceder a los campos de la instancia contenedora. Aquà es donde tropiezan los desarrolladores de Kotlin, porque una clase anidada normal en Kotlin es casi estática y JUnit se negará a ejecutarla como test anidado o no compartirá el estado externo. Combina `inner class` con anotaciones `@DisplayName` descriptivas y obtendrás clases de test del estilo preparar-una-vez, ramificar-muchas que se mantienen legibles a medida que el comportamiento bajo prueba se vuelve más condicional.
La parametrización y el anidamiento se combinan de forma natural: pon un `@ParameterizedTest` dentro de un grupo `@Nested` para expresar 'en este estado, todas estas entradas se comportan igual'. Por ejemplo, una clase anidada 'cuando el descuento está activo' puede ejecutar un test parametrizado sobre muchos precios, mientras que una clase hermana 'cuando el descuento ha expirado' ejecuta las mismas entradas con expectativas distintas. Esto mantiene la matriz de estado-por-entrada explÃcita y pequeña, en lugar de explotar en decenas de métodos a medida. Como regla práctica: usa `@Nested` para *contextos* distintos y `@ParameterizedTest` para *datos distintos dentro de un contexto*.
Estas funciones son independientes del framework y funcionan igual en un test de Spring Boot. Puedes anotar un método de una clase `@SpringBootTest` o `@WebMvcTest` con `@ParameterizedTest`, o colocar clases anidadas dentro de un test de integración para agrupar, por ejemplo, 'peticiones autorizadas' y 'peticiones prohibidas'. La mecánica mostrada aquà corre sobre JUnit Jupiter puro sin contexto de Spring, lo que la hace ideal para tests unitarios rápidos de tu lógica de dominio; reserva la maquinaria más pesada de Spring para los casos que realmente necesitan un contenedor.
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.
🧠Comprueba tu comprensión
0/1 · 0/1 answered1. In Kotlin, a JUnit 5 @MethodSource provider method that supplies arguments must be: