Dynamic Tests
Dynamic tests
La anotación estándar @Test en JUnit Jupiter descrita en Annotations es muy similar a la anotación @Test en JUnit 4. Ambas describen métodos que implementan casos de prueba. Estos casos de prueba son estáticos en el sentido de que están completamente especificados en tiempo de compilación, y su comportamiento no puede ser modificado por nada que ocurra en tiempo de ejecución. Las suposiciones proporcionan una forma básica de comportamiento dinámico, pero están intencionalmente limitadas en su expresividad.
Además de estos pruebas estándar, se ha introducido un modelo completamente nuevo de programación de pruebas en JUnit Jupiter. Este nuevo tipo de prueba es una prueba dinámica que se genera en tiempo de ejecución mediante un método fábrica anotado con @TestFactory.
A diferencia de los métodos @Test, un método @TestFactory no es en sí mismo un caso de prueba, sino una fábrica de casos de prueba. Así, una prueba dinámica es el producto de una fábrica. Técnicamente, un método @TestFactory debe devolver un único DynamicNode o un Stream, Collection, Iterable, Iterator o un arreglo de instancias de DynamicNode. Las subclases instanciables de DynamicNode son DynamicContainer y DynamicTest. Las instancias de DynamicContainer están compuestas por un nombre de presentación y una lista de nodos dinámicos hijos, lo que permite la creación de jerarquías arbitrariamente anidadas de nodos dinámicos. Las instancias de DynamicTest se ejecutarán de manera perezosa, lo que posibilita la generación dinámica e incluso no determinista de casos de prueba.
Cualquier Stream devuelto por un método anotado con @TestFactory será correctamente cerrado al llamar a stream.close(), lo que lo hace seguro para utilizar recursos como Files.lines().
Al igual que los métodos @Test, los métodos @TestFactory no deben ser privados ni estáticos y pueden declarar parámetros opcionales que serán resueltos por ParameterResolvers.
Un DynamicTest es un caso de prueba generado en tiempo de ejecución. Está compuesto por un nombre de presentación y un Executable. Executable es una interfaz funcional (@FunctionalInterface), lo que significa que las implementaciones de las pruebas dinámicas pueden ser proporcionadas como expresiones lambda o referencias de métodos.
❗❗Ciclo de vida de las pruebas dinámicas
El ciclo de vida de ejecución de una prueba dinámica es bastante diferente al de un caso estándar anotado con @Test. Específicamente, no hay callbacks de ciclo de vida para las pruebas dinámicas individuales. Esto significa que los métodos @BeforeEach y @AfterEach y sus correspondientes callbacks de extensión se ejecutan para el método @TestFactory, pero no para cada prueba dinámica. En otras palabras, si accedes a campos de la instancia de la prueba dentro de una expresión lambda para una prueba dinámica, esos campos no se restablecerán mediante métodos de callback ni extensiones entre la ejecución de las pruebas dinámicas individuales generadas por el mismo método @TestFactory.
Dynamic Test Examples
La clase DynamicTestsDemo demuestra varios ejemplos de métodos fábrica de pruebas y pruebas dinámicas.
El primer método devuelve un tipo de retorno no válido. Dado que este tipo inválido no puede ser detectado en tiempo de compilación, se lanza una excepción JUnitException cuando se detecta en tiempo de ejecución.
Los siguientes seis métodos muestran la generación de una Collection, Iterable, Iterator, arreglo o Stream de instancias de DynamicTest. La mayoría de estos ejemplos no muestran un comportamiento realmente dinámico, sino que simplemente demuestran los tipos de retorno compatibles en principio. Sin embargo, los métodos dynamicTestsFromStream() y dynamicTestsFromIntStream() muestran cómo generar pruebas dinámicas a partir de un conjunto dado de cadenas o un rango de números de entrada.
El siguiente método es verdaderamente dinámico por naturaleza. generateRandomNumberOfTests() implementa un Iterator que genera números aleatorios, un generador de nombres de presentación y un ejecutor de pruebas, y luego proporciona los tres a DynamicTest.stream(). Aunque el comportamiento no determinista de generateRandomNumberOfTests() entra en conflicto con la repetibilidad de las pruebas (por lo que debe usarse con precaución), sirve para demostrar la expresividad y el poder de las pruebas dinámicas.
El siguiente método, dynamicTestsFromStreamFactoryMethod(), es similar en flexibilidad a generateRandomNumberOfTests(), pero genera un stream de pruebas dinámicas a partir de un Stream existente mediante el método fábrica DynamicTest.stream().
Con fines demostrativos, el método dynamicNodeSingleTest() genera una sola instancia de DynamicTest en lugar de un stream, y el método dynamicNodeSingleContainer() genera una jerarquía anidada de pruebas dinámicas utilizando DynamicContainer.
import static example.util.StringUtils.isPalindrome;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.DynamicContainer.dynamicContainer;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
import java.util.Arrays;
import java.util.Collection;
import java.util.Iterator;
import java.util.List;
import java.util.Random;
import java.util.function.Function;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import example.util.Calculator;
import org.junit.jupiter.api.DynamicNode;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.TestFactory;
import org.junit.jupiter.api.function.ThrowingConsumer;
class DynamicTestsDemo {
private final Calculator calculator = new Calculator();
// This will result in a JUnitException!
@TestFactory
List<String> dynamicTestsWithInvalidReturnType() {
return Arrays.asList("Hello");
}
@TestFactory
Collection<DynamicTest> dynamicTestsFromCollection() {
return Arrays.asList(
dynamicTest("1st dynamic test", () -> assertTrue(isPalindrome("madam"))),
dynamicTest("2nd dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
);
}
@TestFactory
Iterable<DynamicTest> dynamicTestsFromIterable() {
return Arrays.asList(
dynamicTest("3rd dynamic test", () -> assertTrue(isPalindrome("madam"))),
dynamicTest("4th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
);
}
@TestFactory
Iterator<DynamicTest> dynamicTestsFromIterator() {
return Arrays.asList(
dynamicTest("5th dynamic test", () -> assertTrue(isPalindrome("madam"))),
dynamicTest("6th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
).iterator();
}
@TestFactory
DynamicTest[] dynamicTestsFromArray() {
return new DynamicTest[] {
dynamicTest("7th dynamic test", () -> assertTrue(isPalindrome("madam"))),
dynamicTest("8th dynamic test", () -> assertEquals(4, calculator.multiply(2, 2)))
};
}
@TestFactory
Stream<DynamicTest> dynamicTestsFromStream() {
return Stream.of("racecar", "radar", "mom", "dad")
.map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text))));
}
@TestFactory
Stream<DynamicTest> dynamicTestsFromIntStream() {
// Generates tests for the first 10 even integers.
return IntStream.iterate(0, n -> n + 2).limit(10)
.mapToObj(n -> dynamicTest("test" + n, () -> assertEquals(0, n % 2)));
}
@TestFactory
Stream<DynamicTest> generateRandomNumberOfTests() {
// Generates random positive integers between 0 and 100 until
// a number evenly divisible by 7 is encountered.
Iterator<Integer> inputGenerator = new Iterator<Integer>() {
Random random = new Random();
int current;
@Override
public boolean hasNext() {
current = random.nextInt(100);
return current % 7 != 0;
}
@Override
public Integer next() {
return current;
}
};
// Generates display names like: input:5, input:37, input:85, etc.
Function<Integer, String> displayNameGenerator = (input) -> "input:" + input;
// Executes tests based on the current input value.
ThrowingConsumer<Integer> testExecutor = (input) -> assertTrue(input % 7 != 0);
// Returns a stream of dynamic tests.
return DynamicTest.stream(inputGenerator, displayNameGenerator, testExecutor);
}
@TestFactory
Stream<DynamicTest> dynamicTestsFromStreamFactoryMethod() {
// Stream of palindromes to check
Stream<String> inputStream = Stream.of("racecar", "radar", "mom", "dad");
// Generates display names like: racecar is a palindrome
Function<String, String> displayNameGenerator = text -> text + " is a palindrome";
// Executes tests based on the current input value.
ThrowingConsumer<String> testExecutor = text -> assertTrue(isPalindrome(text));
// Returns a stream of dynamic tests.
return DynamicTest.stream(inputStream, displayNameGenerator, testExecutor);
}
@TestFactory
Stream<DynamicNode> dynamicTestsWithContainers() {
return Stream.of("A", "B", "C")
.map(input -> dynamicContainer("Container " + input, Stream.of(
dynamicTest("not null", () -> assertNotNull(input)),
dynamicContainer("properties", Stream.of(
dynamicTest("length > 0", () -> assertTrue(input.length() > 0)),
dynamicTest("not empty", () -> assertFalse(input.isEmpty()))
))
)));
}
@TestFactory
DynamicNode dynamicNodeSingleTest() {
return dynamicTest("'pop' is a palindrome", () -> assertTrue(isPalindrome("pop")));
}
@TestFactory
DynamicNode dynamicNodeSingleContainer() {
return dynamicContainer("palindromes",
Stream.of("racecar", "radar", "mom", "dad")
.map(text -> dynamicTest(text, () -> assertTrue(isPalindrome(text)))
));
}
}
Dynamic Tests and Named
En algunos casos, puede resultar más natural especificar las entradas junto con un nombre descriptivo utilizando la API Named y los métodos fábrica stream() correspondientes en DynamicTest, como se muestra en el primer ejemplo a continuación.
El segundo ejemplo lleva esto un paso más allá y permite proporcionar el bloque de código que debe ejecutarse implementando la interfaz Executable junto con Named, a través de la clase base NamedExecutable.
import static example.util.StringUtils.isPalindrome;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Named.named;
import java.util.stream.Stream;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.NamedExecutable;
import org.junit.jupiter.api.TestFactory;
public class DynamicTestsNamedDemo {
@TestFactory
Stream<DynamicTest> dynamicTestsFromStreamFactoryMethodWithNames() {
// Stream of palindromes to check
var inputStream = Stream.of(
named("racecar is a palindrome", "racecar"),
named("radar is also a palindrome", "radar"),
named("mom also seems to be a palindrome", "mom"),
named("dad is yet another palindrome", "dad")
);
// Returns a stream of dynamic tests.
return DynamicTest.stream(inputStream, text -> assertTrue(isPalindrome(text)));
}
@TestFactory
Stream<DynamicTest> dynamicTestsFromStreamFactoryMethodWithNamedExecutables() {
// Stream of palindromes to check
var inputStream = Stream.of("racecar", "radar", "mom", "dad")
.map(PalindromeNamedExecutable::new);
// Returns a stream of dynamic tests based on NamedExecutables.
return DynamicTest.stream(inputStream);
}
record PalindromeNamedExecutable(String text) implements NamedExecutable {
@Override
public String getName() {
return String.format("'%s' is a palindrome", text);
}
@Override
public void execute() {
assertTrue(isPalindrome(text));
}
}
}
URI Test Sources for Dynamic Tests
La plataforma JUnit proporciona TestSource, una representación de la fuente de un test o contenedor, que es utilizada por IDEs y herramientas de construcción para navegar hasta su ubicación.
El TestSource para una prueba dinámica o un contenedor dinámico puede construirse a partir de un java.net.URI, el cual se puede proporcionar mediante los métodos fábrica:
DynamicTest.dynamicTest(String, URI, Executable)DynamicContainer.dynamicContainer(String, URI, Stream)
El URI será convertido en una de las siguientes implementaciones de TestSource:
Tipos de TestSource:
-
ClasspathResourceSourceSi el URI contiene el esquema
classpath— por ejemplo:classpath:/test/foo.xml?line=20,column=2 -
DirectorySourceSi el URI representa un directorio existente en el sistema de archivos.
-
FileSourceSi el URI representa un archivo existente en el sistema de archivos.
-
MethodSourceSi el URI contiene el esquema
methody el nombre completamente calificado del método (FQMN) — por ejemplo:method:org.junit.Foo#bar(java.lang.String, java.lang.String[])Consulta el Javadoc de
DiscoverySelectors.selectMethodpara los formatos compatibles de FQMN. -
ClassSourceSi el URI contiene el esquema
classy el nombre completamente calificado de la clase — por ejemplo:class:org.junit.Foo?line=42 -
UriSourceSi ninguna de las implementaciones anteriores es aplicable, se utiliza esta como caso genérico.
🧪 Ejemplo de DynamicTest con TestSource desde un URI
java
CopiarEditar
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.stream.Stream;
import static org.junit.jupiter.api.DynamicTest.dynamicTest;
public class UriTestSourceExample {
@TestFactory
Stream<DynamicTest> dynamicTestsFromUris() {
return Stream.of("test1.txt", "test2.txt")
.map(filename -> {
URI uri = Paths.get("src/test/resources/" + filename).toUri();
return dynamicTest("Validar contenido de " + filename, uri, () -> {
String content = Files.readString(Paths.get(uri));
// Validación de ejemplo
if (content.isBlank()) {
throw new AssertionError("El archivo está vacío");
}
});
});
}
}
🔍 ¿Qué está pasando aquí?
- Se definen dos archivos (
test1.txtytest2.txt) que supuestamente están ensrc/test/resources/. - Para cada uno:
- Se genera un
URI. - Se crea un
DynamicTestcon eseURIcomo origen. - Se define una lógica de validación dentro del bloque
Executable(en este caso, verificar que el contenido no esté vacío).
- Se genera un
🎯 Ventajas
- El
URIse convierte internamente en unFileSource, ya que apunta a un archivo del sistema. - Esto le permite a herramientas como IntelliJ o Gradle vincular la prueba directamente al archivo fuente (para abrirlo o mostrar detalles).
- Muy útil para pruebas basadas en archivos de configuración, JSONs, XMLs, etc.