JUnit 5: Extensions, Tests Parametrizados, Dynamic Tests
Dominá JUnit 5 para testing moderno en Java: modelo de extensions, tests parametrizados, dynamic tests, test interfaces, lifecycle, conditional execution y JUnit Platform.
Nota para desarrolladores hispanohablantes: Esta guía incluye ejemplos y convenciones de nomenclatura adaptadas a equipos que trabajan en español. Cuando existen diferencias significativas en terminología técnica entre el inglés y el español, se indican explícitamente para facilitar la comunicación en equipos multiculturales.
Introducción
JUnit 5 es una reescritura completa del framework JUnit, construido como una plataforma modular en vez de una sola librería. Introduce un modelo de extensions que reemplaza los runners y rules de JUnit 4, tests parametrizados de primera clase, generación de dynamic tests y el JUnit Platform para correr test engines. A continuación: las features que importan para codebases de Java en producción: extensions personalizados, patrones de tests parametrizados, dynamic tests para escenarios data-driven, conditional execution e integración con Maven y Gradle.
Arquitectura
JUnit 5 consiste en tres módulos:
- JUnit Platform: La fundación para lanzar testing frameworks en la JVM. Provee el
TestEngineAPI para IDEs y build tools. - JUnit Jupiter: El nuevo programming y extension model. Esto es lo que usás para escribir tests (
@Test,@ExtendWith, etc.). - JUnit Vintage: Una capa de compatibilidad para correr tests de JUnit 3 y 4 junto con JUnit 5.
Lifecycle y Básicos
Anotaciones de test lifecycle
// LifecycleTest.java — JUnit 5 lifecycle
import org.junit.jupiter.api.*;
class LifecycleTest {
@BeforeAll
static void setUpAll() {
// Corre una vez antes de todos los tests en la class
// Debe ser static
}
@BeforeEach
void setUp() {
// Corre antes de cada test
}
@Test
void firstTest() {
// Test logic here
}
@Test
void secondTest() {
// Test logic here
}
@AfterEach
void tearDown() {
// Corre después de cada test
}
@AfterAll
static void tearDownAll() {
// Corre una vez después de todos los tests en la class
// Debe ser static
}
}
Display names y tags
// TaggedTest.java — Custom display names y tags
import org.junit.jupiter.api.*;
@DisplayName("Order Service Tests")
class OrderServiceTest {
@Test
@DisplayName("should calculate total with tax")
@Tag("unit")
void testCalculateTotalWithTax() {
// Test logic
}
@Test
@DisplayName("should reject order with negative quantity")
@Tag("unit")
@Tag("validation")
void testNegativeQuantity() {
// Test logic
}
@Test
@DisplayName("should persist order to database")
@Tag("integration")
@Tag("slow")
void testOrderPersistence() {
// Test logic
}
}
Assertions
// AssertionsTest.java — JUnit 5 assertions
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class AssertionsTest {
@Test
void testBasicAssertions() {
assertEquals(4, 2 + 2);
assertNotEquals(5, 2 + 2);
assertTrue(5 > 3);
assertFalse(5 < 3);
assertNull(null);
assertNotNull(new Object());
}
@Test
void testWithMessage() {
assertEquals(4, 2 + 2, "2 + 2 should equal 4");
// Lazy message usando lambda (solo evaluado en failure)
assertEquals(4, 2 + 2, () -> "Calculation failed at runtime");
}
@Test
void testGroupedAssertions() {
// Todas las assertions corren incluso si una falla
assertAll("user validation",
() -> assertEquals("Alice", user.getName()),
() -> assertEquals("alice@example.com", user.getEmail()),
() -> assertTrue(user.isActive())
);
}
@Test
void testExceptions() {
IllegalStateException ex = assertThrows(
IllegalStateException.class,
() -> service.processInvalidOrder()
);
assertEquals("Order cannot be processed", ex.getMessage());
}
@Test
void testTimeout() {
assertTimeout(
java.time.Duration.ofSeconds(2),
() -> service.fetchData()
);
// Preemptive: killea la operation si timeout exceeded
assertTimeoutPreemptively(
java.time.Duration.ofMillis(500),
() -> service.quickLookup()
);
}
}
Tests Parametrizados
Tests parametrizados básicos
// ParameterizedTest.java — Tests parametrizados con various sources
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.*;
@ParameterizedTest
@ValueSource(ints = {1, 2, 3, 4, 5})
void testPositiveNumbers(int number) {
assertTrue(number > 0);
}
@ParameterizedTest
@ValueSource(strings = {"", " ", "\t", "\n"})
void testBlankStrings(String input) {
assertTrue(input.isBlank());
}
@ParameterizedTest
@NullAndEmptySource
@ValueSource(strings = {" ", "\t"})
void testBlankOrNullStrings(String input) {
assertTrue(input == null || input.isBlank());
}
CSV source
// CsvSourceTest.java — Tests parametrizados basados en CSV
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@ParameterizedTest
@CsvSource({
"1, 1, 2",
"2, 3, 5",
"10, 20, 30",
"-1, 1, 0",
"0, 0, 0"
})
void testAddition(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest
@CsvSource({
"Alice, 30, true",
"Bob, 17, false",
"Charlie, 18, true",
"Diana, 16, false"
})
void testAgeValidation(String name, int age, boolean canVote) {
Person person = new Person(name, age);
assertEquals(canVote, person.canVote());
}
Method source
// MethodSourceTest.java — Data desde methods
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import java.util.stream.Stream;
static Stream<Arguments> provideTestData() {
return Stream.of(
Arguments.of("admin", "ADMIN", true),
Arguments.of("user", "USER", true),
Arguments.of("guest", "GUEST", true),
Arguments.of("admin", "USER", false)
);
}
@ParameterizedTest
@MethodSource("provideTestData")
void testRoleCheck(String username, String expectedRole, boolean match) {
User user = userService.findByUsername(username);
assertEquals(match, user.hasRole(expectedRole));
}
// Usando objects directamente
record TestCase(String input, String expected, String description) {}
static Stream<TestCase> provideComplexTestData() {
return Stream.of(
new TestCase("hello", "HELLO", "lowercase to uppercase"),
new TestCase("WORLD", "WORLD", "already uppercase"),
new TestCase("", "", "empty string"),
new TestCase("123", "123", "numeric string")
);
}
@ParameterizedTest
@MethodSource("provideComplexTestData")
void testUppercase(TestCase tc) {
assertEquals(tc.expected(), tc.input().toUpperCase());
}
Custom display names para parámetros
// DisplayNameTest.java — Custom parameterized display names
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
@ParameterizedTest(name = "[{index}] {0} + {1} = {2}")
@CsvSource({
"1, 2, 3",
"10, 20, 30",
"100, 200, 300"
})
void testAdditionWithName(int a, int b, int expected) {
assertEquals(expected, calculator.add(a, b));
}
@ParameterizedTest(name = "{0} should be valid")
@ValueSource(strings = {"user@example.com", "admin@company.org"})
void testValidEmails(String email) {
assertTrue(validator.isValid(email));
}
Modelo de Extensions
Extension personalizado
// RetryExtension.java — Retry automático de tests fallidos
import org.junit.jupiter.api.extension.*;
import java.lang.reflect.Method;
public class RetryExtension implements InvocationInterceptor {
private static final int MAX_RETRIES = 3;
@Override
public void interceptTestTemplateMethod(
Invocation<Void> invocation,
ReflectiveInvocationContext<Method> invocationContext,
ExtensionContext extensionContext) throws Throwable {
Throwable lastError = null;
for (int attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
invocation.proceed();
return; // Success, no retry needed
} catch (Throwable t) {
lastError = t;
System.out.printf("Attempt %d failed: %s%n", attempt, t.getMessage());
}
}
throw lastError;
}
}
// Usage
@ExtendWith(RetryExtension.class)
class FlakyServiceTest {
@Test
@DisplayName("should connect to external API")
void testExternalApiConnection() {
// Retried hasta 3 veces si falla
assertTrue(apiService.connect());
}
}
Parameter resolver extension
// DatabaseExtension.java — Inyectá database connections en tests
import org.junit.jupiter.api.extension.*;
import org.junit.jupiter.api.*;
public class DatabaseExtension implements ParameterResolver, AfterTestExecutionCallback {
private DatabaseConnection connection;
@Override
public boolean supportsParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
return parameterContext.getParameter().getType() == DatabaseConnection.class;
}
@Override
public Object resolveParameter(ParameterContext parameterContext,
ExtensionContext extensionContext) {
connection = new DatabaseConnection("jdbc:postgresql://localhost/test");
connection.connect();
return connection;
}
@Override
public void afterTestExecution(ExtensionContext context) {
if (connection != null) {
connection.close();
}
}
}
// Usage
@ExtendWith(DatabaseExtension.class)
class OrderRepositoryTest {
@Test
void testFindById(DatabaseConnection db) {
// db es automáticamente inyectado
OrderRepository repo = new OrderRepository(db);
Order order = repo.findById(1L);
assertNotNull(order);
}
}
Conditional test execution
// ConditionalExtension.java — Corré tests basado en environment
import org.junit.jupiter.api.extension.*;
import java.util.Optional;
public class EnvironmentCondition implements ExecutionCondition {
@Override
public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) {
String env = System.getProperty("test.env", "local");
// Checkeá @RequiresStaging annotation
Optional<RequiresStaging> annotation = context.getTestMethod()
.flatMap(method -> Optional.ofNullable(
method.getAnnotation(RequiresStaging.class)));
if (annotation.isPresent() && !"staging".equals(env)) {
return ConditionEvaluationResult.disabled("Requires staging environment");
}
return ConditionEvaluationResult.enabled("Environment check passed");
}
}
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith(EnvironmentCondition.class)
public @interface RequiresStaging {}
// Usage
class PaymentServiceTest {
@Test
@RequiresStaging
void testRealPaymentGateway() {
// Solo corre cuando -Dtest.env=staging
}
}
Dynamic Tests
Dynamic tests básicos
// DynamicTestTest.java — Generá tests en runtime
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.DynamicTest;
import static org.junit.jupiter.api.DynamicTest.*;
import java.util.stream.Stream;
import java.util.List;
class DynamicTestTest {
@TestFactory
Stream<DynamicTest> testStringOperations() {
List<String> inputs = List.of("hello", "world", "java", "test");
return inputs.stream()
.map(input -> dynamicTest(
"uppercase: " + input,
() -> assertEquals(input.toUpperCase(), input.toUpperCase())
));
}
@TestFactory
List<DynamicTest> testMultipleScenarios() {
return List.of(
dynamicTest("add positive numbers",
() -> assertEquals(5, calculator.add(2, 3))),
dynamicTest("add negative numbers",
() -> assertEquals(-5, calculator.add(-2, -3))),
dynamicTest("add zero",
() -> assertEquals(5, calculator.add(5, 0)))
);
}
}
Data-driven dynamic tests
// DataDrivenTest.java — Dynamic tests desde data externa
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.DynamicTest.*;
import java.util.stream.Stream;
import java.util.List;
class DataDrivenTest {
record TestCase(String name, Runnable test) {}
@TestFactory
Stream<DynamicTest> testAllValidationRules() {
List<TestCase> cases = List.of(
new TestCase("email is required", () -> {
assertThrows(ValidationException.class,
() -> validator.validateEmail(null));
}),
new TestCase("email format is checked", () -> {
assertThrows(ValidationException.class,
() -> validator.validateEmail("not-an-email"));
}),
new TestCase("valid email passes", () -> {
assertDoesNotThrow(
() -> validator.validateEmail("user@example.com"));
}),
new TestCase("email with subdomain passes", () -> {
assertDoesNotThrow(
() -> validator.validateEmail("user@mail.example.com"));
})
);
return cases.stream()
.map(tc -> dynamicTest(tc.name(), tc.test()));
}
}
Test Interfaces
Default methods en test interfaces
// TestInterface.java — Shared test logic via interfaces
import org.junit.jupiter.api.*;
public interface ComparableContract<T extends Comparable<T>> {
T createSmallerValue();
T createLargerValue();
@Test
@DisplayName("returns positive when comparing smaller to larger")
default void returnsPositiveWhenSmallerComparedToLarger() {
T smaller = createSmallerValue();
T larger = createLargerValue();
assertTrue(smaller.compareTo(larger) < 0);
}
@Test
@DisplayName("returns negative when comparing larger to smaller")
default void returnsNegativeWhenLargerComparedToSmaller() {
T smaller = createSmallerValue();
T larger = createLargerValue();
assertTrue(larger.compareTo(smaller) > 0);
}
@Test
@DisplayName("returns zero when comparing to self")
default void returnsZeroWhenComparedToSelf() {
T value = createSmallerValue();
assertEquals(0, value.compareTo(value));
}
}
// Implementation
class StringComparableTest implements ComparableContract<String> {
@Override
public String createSmallerValue() {
return "apple";
}
@Override
public String createLargerValue() {
return "banana";
}
}
Nested Tests
// NestedTest.java — Groupéa tests relacionados en nested classes
import org.junit.jupiter.api.*;
@DisplayName("Order Service")
class OrderServiceTest {
@Nested
@DisplayName("when creating an order")
class WhenCreating {
@Test
@DisplayName("should assign a unique ID")
void shouldAssignId() {
Order order = service.create(new OrderRequest("item1", 2));
assertNotNull(order.getId());
}
@Test
@DisplayName("should set status to pending")
void shouldSetPendingStatus() {
Order order = service.create(new OrderRequest("item1", 2));
assertEquals(OrderStatus.PENDING, order.getStatus());
}
@Nested
@DisplayName("with invalid input")
class WithInvalidInput {
@Test
@DisplayName("should reject empty item ID")
void shouldRejectEmptyItem() {
assertThrows(ValidationException.class,
() -> service.create(new OrderRequest("", 2)));
}
@Test
@DisplayName("should reject negative quantity")
void shouldRejectNegativeQuantity() {
assertThrows(ValidationException.class,
() -> service.create(new OrderRequest("item1", -1)));
}
}
}
@Nested
@DisplayName("when canceling an order")
class WhenCanceling {
@Test
@DisplayName("should set status to canceled")
void shouldSetCanceledStatus() {
Order order = service.create(new OrderRequest("item1", 2));
service.cancel(order.getId());
assertEquals(OrderStatus.CANCELED, order.getStatus());
}
@Test
@DisplayName("should reject canceling shipped orders")
void shouldRejectShippedCancellation() {
Order order = service.create(new OrderRequest("item1", 2));
service.ship(order.getId());
assertThrows(IllegalStateException.class,
() -> service.cancel(order.getId()));
}
}
}
Configuración de Build
Maven
<!-- pom.xml — JUnit 5 con Maven Surefire -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-params</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<groups>unit</groups>
<excludedGroups>slow,integration</excludedGroups>
</configuration>
</plugin>
</plugins>
</build>
Gradle
// build.gradle — JUnit 5 con Gradle
dependencies {
testImplementation platform('org.junit:junit-bom:5.11.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
testImplementation 'org.junit.jupiter:junit-jupiter-params'
}
test {
useJUnitPlatform()
filter {
includeTags 'unit'
excludeTags 'slow', 'integration'
}
testLogging {
events 'passed', 'skipped', 'failed'
exceptionFormat 'full'
}
}
// Task separado para integration tests
task integrationTest(type: Test) {
useJUnitPlatform()
filter {
includeTags 'integration'
}
}
Best Practices
-
For a deeper guide, see JUnit5 Soft Assertions with AssertJ.
-
Usá
@DisplayNamepara nombres de test legibles — los reports de test se vuelven documentation -
Preferí tests parametrizados sobre métodos de test duplicados
-
Usá
assertAllpara grouped assertions — todos los checks corren incluso si uno falla -
Usá
@Tagpara clasificar tests — habilita ejecución selectiva en CI -
Escribí extensions personalizados para cross-cutting test concerns — no repetir setup code
-
Usá nested tests para estructurar escenarios de test complejos — mejora readability
-
Usá
@TestFactorypara data-driven tests desde fuentes externas -
Seteá lazy assertion messages con lambdas — evita string concatenation en success
-
Usá
@Nestedpara sharear setup dentro de un grupo de tests relacionados -
Mantené unit tests bajo 100ms — mockeá dependencias externas
Common Mistakes
- Usar
@BeforeAllsin static: JUnit 5 requiere que@BeforeAllmethods sean static a menos que uses@TestInstance(Lifecycle.PER_CLASS). - No usar
assertAll: Cuando múltiples assertions fallan, solo ves la primera.assertAllreporta todos los failures. - Overusear
@Disabled: Tests disabled se acumulan y se vuelven stale. Deletealos o fixealos. - Ignorar parameterized test names: Los nombres default son feos. Usá
@ParameterizedTest(name = "...")para output legible. - No usar tags: Sin tags, no podés correr tests selectivamente en CI vs local development.
FAQ
¿Cuál es la diferencia entre JUnit 4 y JUnit 5?
JUnit 5 es una reescritura completa con arquitectura modular (Platform, Jupiter, Vintage). Reemplaza runners/rules con extensions, agrega tests parametrizados de primera clase, dynamic tests, nested tests y mejor assertion API. Los tests de JUnit 4 pueden correr via el Vintage engine.
¿Cómo corro tests de JUnit 5 en Maven?
Agregá la dependencia junit-jupiter y configurá maven-surefire-plugin 3.x con useJUnitPlatform(). Corré con mvn test.
¿Qué son las extensions de JUnit 5?
Las extensions reemplazan @RunWith y @Rule de JUnit 4. Implementá interfaces como ParameterResolver, InvocationInterceptor o ExecutionCondition para agregar comportamiento custom. Registrá con @ExtendWith.
¿Cómo funcionan los tests parametrizados en JUnit 5?
Usá @ParameterizedTest con una source annotation (@ValueSource, @CsvSource, @MethodSource, @EnumSource). El test method corre una vez por cada set de parámetros.
¿Qué son los dynamic tests?
Tests generados en runtime via @TestFactory. Útiles para data-driven testing donde los test cases vienen de fuentes externas (files, databases, APIs). Cada dynamic test tiene un display name y un executable.
Recursos Relacionados
Test-Driven Development (TDD) — A Practical Workflow
Learn TDD step by step: write a failing test, make it pass, refactor. Red-Green-Refactor with real examples in Python, JavaScript, and Java.
GuideSoftware Testing Strategy Guide
A practical guide to building a layered testing strategy with unit, integration, and end-to-end tests.
RecipeAPI Mocking for Testing
Build reliable tests by mocking external APIs with WireMock, MockServer, and MSW to eliminate flakiness and test edge cases.
RecipeJUnit5 Soft Assertions with AssertJ
How to use AssertJ soft assertions in JUnit5 to collect multiple assertion failures in a single test instead of stopping at the first failure.
RecipeJava Testcontainers Integration Tests
How to use Testcontainers in JUnit5 to spin up real Postgres, Redis, and Kafka containers for integration tests that are reliable and reproducible.
GuideComplete Guide to GraphQL Testing
Test GraphQL APIs at every layer: unit tests for resolvers, integration tests for schema, E2E tests for operations. Covers mocking, fixtures, snapshot testing, and performance testing.