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:

  • ClasspathResourceSource

    Si el URI contiene el esquema classpath — por ejemplo:

    classpath:/test/foo.xml?line=20,column=2

  • DirectorySource

    Si el URI representa un directorio existente en el sistema de archivos.

  • FileSource

    Si el URI representa un archivo existente en el sistema de archivos.

  • MethodSource

    Si el URI contiene el esquema method y 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.selectMethod para los formatos compatibles de FQMN.

  • ClassSource

    Si el URI contiene el esquema class y el nombre completamente calificado de la clase — por ejemplo:

    class:org.junit.Foo?line=42

  • UriSource

    Si 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.txt y test2.txt) que supuestamente están en src/test/resources/.
  • Para cada uno:
    • Se genera un URI.
    • Se crea un DynamicTest con ese URI como origen.
    • Se define una lógica de validación dentro del bloque Executable (en este caso, verificar que el contenido no esté vacío).

🎯 Ventajas

  • El URI se convierte internamente en un FileSource, 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.