Este artículo explora en profundidad los mecanismos internos del framework de pruebas JUnit 5, desde el momento en que una IDE lanza la ejecución hasta que los resultados de las pruebas se reportan al usuario. Se abordan los componentes fundamentales de la arquitectura, el proceso de descubrimiento de pruebas y el ciclo de ejecución jerárquico.
Arquitectura modular de JUnit 5
JUnit 5 se compone de varios módulos con responsabilidades claramente delimitadas:
- junit-jupiter-api: Proporciona las anotaciones, aserciones y extensiones que los desarrolladores utilizan para escribir sus pruebas.
- junit-platform-engine: Define la interfaz
TestEngineque todo motor de pruebas debe implementar, permitiendo que diferentes frameworks (JUnit 4, TestNG, Spock, Cucumber) se integren de forma unificada. - junit-jupiter-engine: Implementación concreta de
TestEnginediseñada exclusivamente para ejecutar pruebas escritas con la API de JUnit Jupiter. - junit-vintage-engine: Implementación alternativa de
TestEngineque actúa como puente para ejecutar pruebas heredadas de JUnit 3 y JUnit 4. - junit-platform-launcher: Orquesta el descubrimiento y la ejecución utilizando un mecanismo de carga de servicios (
ServiceLoader). Expone una API que las IDE y herramientas de construcción emplean para interactuar con el ciclo de vida de las pruebas.
Punto de entrada: la petición de la IDE
Cuando el usuario inicia una prueba desde IntelliJ IDEA, el plugin de JUnit 5 de la IDE construye un objeto LauncherDiscoveryRequest que encapsula toda la información necesaria. Este objeto implementa la interfaz EngineDiscoveryRequest y contiene dos colecciones esenciales:
- Selectores (
DiscoverySelector): Indican qué recursos explorar. En el caso habitual de ejecución desde la IDE, se utilizaClasspathRootSelector, que almacena la URI de la raíz del classpath donde residen las clases de prueba. - Filtros (
DiscoveryFilter): Permiten incluir o excluir clases y métodos durante la fase de descubrimiento.
final class PeticionDescubrimientoPorDefecto implements LauncherDiscoveryRequest {
private final List<DiscoverySelector> selectores;
private final List<DiscoveryFilter<?>> filtrosDeDescubrimiento;
// constructores y métodos de acceso omitidos por brevedad
}
El launcher (DefaultLauncher) recibe esta petición y delega en los motores de prueba registrados. El método discover constituye el punto de partida real dentro del código de JUnit 5.
Estructuras de datos del plan de pruebas
Antes de analizar el flujo de descubrimiento, conviene entender las estructuras que almacenan el resultado de dicho proceso.
TestDescriptor
Es la abstracción central que describe un nodo en el árbol de pruebas. Existen varias implementaciones concretas:
JupiterEngineDescriptor: Nodo raíz que representa al propio motor.ClassTestDescriptor: Representa una clase de prueba (sin clases anidadas).TestMethodTestDescriptor: Representa un método de prueba individual.
TestPlan
Agrega todos los TestDescriptor en una estructura navegable, indexada por identificador único (UniqueId):
public class PlanDePruebas {
private final Set<TestIdentifier> raices;
private final Map<String, Set<TestIdentifier>> descendientes;
private final Map<String, TestIdentifier> todosLosIdentificadores;
private final boolean contienePruebas;
public static PlanDePruebas desde(Collection<TestDescriptor> descriptoresDeMotor) {
PlanDePruebas plan = new PlanDePruebas(
descriptoresDeMotor.stream().anyMatch(TestDescriptor::containsTests));
Visitor visitante = descriptor -> plan.agregar(TestIdentifier.from(descriptor));
descriptoresDeMotor.forEach(desc -> desc.accept(visitante));
return plan;
}
}
Root y InternalTestPlan
La clase Root mantiene la asociación entre cada TestEngine y su descriptor raíz, junto con los parámetros de configuración recibidos de la IDE:
class Raiz {
private final Map<TestEngine, TestDescriptor> motoresConDescriptores;
private final ConfigurationParameters parametrosDeConfiguracion;
}
InternalTestPlan extiende TestPlan mediante delegación y añade la referencia al objeto Root, necesario durante la fase de ejecución.
Proceso de descubrimiento de pruebas
El método discoverRoot del launcher orquesta todo el proceso de descubrimiento:
private Raiz explorarRaiz(LauncherDiscoveryRequest peticion, String fase) {
Raiz raiz = new Raiz(peticion.getConfigurationParameters());
for (TestEngine motor : this.motoresRegistrados) {
Optional<TestDescriptor> descriptorMotor = descubrirRaizMotor(motor, peticion);
descriptorMotor.ifPresent(desc -> raiz.agregar(motor, desc));
}
raiz.aplicarFiltrosPosteriores(peticion);
raiz.podar();
return raiz;
}
Para cada motor, se invoca su método discover. En el caso de JupiterTestEngine:
@Override
public TestDescriptor discover(EngineDiscoveryRequest peticion, UniqueId idUnico) {
JupiterConfiguration config = new ConfiguracionJupiterCacheada(
new ConfiguracionJupiterPorDefecto(peticion.getConfigurationParameters()));
JupiterEngineDescriptor descriptorMotor = new JupiterEngineDescriptor(idUnico, config);
new ResolvedorSelectoresDescubrimiento().resolverSelectores(peticion, descriptorMotor);
return descriptorMotor;
}
Selectores y resolutores
El paquete DiscoverySelectorResolver configura un pipeline de resolución basado en el patrón Strategy. Define resolutores específicos para cada tipo de selector y visitantes para procesamiento posterior:
public class ResolvedorSelectoresDescubrimiento {
private static final EngineDiscoveryRequestResolver<JupiterEngineDescriptor> resolvedor =
EngineDiscoveryRequestResolver.<JupiterEngineDescriptor>builder()
.addClassContainerSelectorResolver(new EsClaseDePruebaConTests())
.addSelectorResolver(ctx ->
new ResolvedorSelectorClase(ctx.getClassNameFilter(),
ctx.getEngineDescriptor().getConfiguration()))
.addSelectorResolver(ctx ->
new ResolvedorSelectorMetodo(ctx.getEngineDescriptor().getConfiguration()))
.addTestDescriptorVisitor(ctx ->
new VisitanteOrdenamientoMetodos(ctx.getEngineDescriptor().getConfiguration()))
.addTestDescriptorVisitor(ctx -> TestDescriptor::prune)
.build();
public void resolverSelectores(EngineDiscoveryRequest peticion,
JupiterEngineDescriptor descriptorMotor) {
resolvedor.resolve(peticion, descriptorMotor);
}
}
Internamente, el resolvedor itera sobre todos los selectores de la petición y los procesa secuencialmente. Cada selector se intenta resolver contra la lista de SelectorResolver registrados:
private Optional<Resolucion> resolver(DiscoverySelector selector) {
if (selectoresYaResueltos.containsKey(selector)) {
return Optional.of(selectoresYaResueltos.get(selector));
}
return resolver(selector, resolvedor -> {
Contexto contexto = obtenerContexto(selector);
if (selector instanceof ClasspathRootSelector) {
return resolvedor.resolver((ClasspathRootSelector) selector, contexto);
}
if (selector instanceof ClassSelector) {
return resolvedor.resolver((ClassSelector) selector, contexto);
}
if (selector instanceof MethodSelector) {
return resolvedor.resolver((MethodSelector) selector, contexto);
}
return resolvedor.resolver(selector, contexto);
});
}
Resolución de ClasspathRootSelector
Cuando el selector es de tipo ClasspathRootSelector, el resolutor correspondiente escanea el classpath buscando clases que pasen los filtros configurados. Por cada clase encontrada, genera un ClassSelector y lo encola para procesamiento posterior:
public Resolucion resolver(ClasspathRootSelector selector, Contexto contexto) {
List<Class<?>> clases = buscarTodasLasClasesEnRaizClasspath(
selector.getClasspathRoot(), filtroClase, filtroNombreClase);
return crearSelectoresClase(clases);
}
private Resolucion crearSelectoresClase(List<Class<?>> clases) {
return Resolucion.selectores(
clases.stream()
.map(DiscoverySelectors::selectClass)
.collect(Collectors.toSet()));
}
Resolución de ClassSelector
Al procesar un ClassSelector, el resolutor verifica que la clase sea efectivamente una clase de prueba válida. Si lo es, crea un ClassTestDescriptor y genera selectores hijos para los métodos de prueba y clases anidadas:
public Resolucion resolver(ClassSelector selector, Contexto contexto) {
Class<?> clasePrueba = selector.getJavaClass();
if (!esClaseDePrueba.test(clasePrueba)) {
return Resolucion.sinResolver();
}
if (!filtroNombreClase.test(clasePrueba.getName())) {
return Resolucion.sinResolver();
}
return convertirAResolucion(
contexto.agregarAlPadre(padre ->
Optional.of(crearDescriptorClase(padre, clasePrueba))));
}
private Resolucion convertirAResolucion(Optional<TestDescriptor> descriptorOpt) {
return descriptorOpt.map(descriptor -> {
Class<?> clasePrueba = ((ClassTestDescriptor) descriptor).getTestClass();
List<Class<?>> clasesContenedoras = new ArrayList<>(
descriptor.getEnclosingTestClasses());
clasesContenedoras.add(clasePrueba);
return Resolucion.coincidencia(
Coincidencia.exacta(descriptor, () -> {
Stream<DiscoverySelector> metodos = buscarMetodos(clasePrueba,
EsMetodoDePrueba::new).stream()
.map(m -> selectMethod(clasesContenedoras, m));
Stream<NestedClassSelector> anidadas = buscarClasesAnidadas(
clasePrueba, EsClaseAnidada::new).stream()
.map(c -> new NestedClassSelector(clasesContenedoras, c));
return Stream.concat(metodos, anidadas)
.collect(Collectors.toCollection(LinkedHashSet::new));
}));
}).orElse(Resolucion.sinResolver());
}
Resolución de MethodSelector
Para cada método de prueba, el resolutor determina su tipo (test estándar, test factory, test template, etc.) y crea el TestDescriptor apropiado:
private enum TipoMetodo {
TEST(new EsMetodoDeTest(), TestMethodTestDescriptor.SEGMENT_TYPE) {
@Override
protected TestDescriptor crearDescriptor(UniqueId id, Class<?> clase,
Method metodo, JupiterConfiguration config) {
return new TestMethodTestDescriptor(id, clase, metodo, config);
}
},
TEST_FACTORY(new EsMetodoFactory(), TestFactoryTestDescriptor.SEGMENT_TYPE) {
@Override
protected TestDescriptor crearDescriptor(UniqueId id, Class<?> clase,
Method metodo, JupiterConfiguration config) {
return new TestFactoryTestDescriptor(id, clase, metodo, config);
}
};
private final Predicate<Method> predicadoMetodo;
private final String tipoSegmento;
TipoMetodo(Predicate<Method> predicado, String segmento) {
this.predicadoMetodo = predicado;
this.tipoSegmento = segmento;
}
Optional<TestDescriptor> resolver(List<Class<?>> clasesContenedoras,
Class<?> clasePrueba, Method metodo, Contexto contexto,
JupiterConfiguration config) {
if (!predicadoMetodo.test(metodo)) {
return Optional.empty();
}
return contexto.agregarAlPadre(
() -> selectClass(clasesContenedoras, clasePrueba),
padre -> Optional.of(crearDescriptor(
construirIdUnico(metodo, padre), clasePrueba, metodo, config)));
}
}
La construcción del UniqueId sigue una jerarquía: [engine:class:method], lo que garantiza la unicidad de cada nodo en el árbol de pruebas.
Fase de ejecución
Una vez completado el descubrimiento, el launcher inicia la ejecución. El punto de entrada es DefaultLauncher.execute():
@Override
public void execute(LauncherDiscoveryRequest peticion,
TestExecutionListener... oyentes) {
PlanInternoDePruebas planInterno = PlanInternoDePruebas.desde(
explorarRaiz(peticion, "ejecucion"));
ejecutarConPlan(planInterno, oyentes);
}
Creación del contexto de ejecución del motor
Dado que JupiterTestEngine no implementa directamente execute, la llamada se resuelve en la clase abstracta HierarchicalTestEngine:
@Override
public final void execute(ExecutionRequest peticion) {
try (HierarchicalTestExecutorService servicioEjecucion =
crearServicioEjecucion(peticion)) {
C contextoEjecucion = crearContextoEjecucion(peticion);
ThrowableCollector.Factory fabricaColeccionador =
crearFabricaColeccionadorExcepciones(peticion);
new EjecutorJerarquico<>(peticion, contextoEjecucion,
servicioEjecucion, fabricaColeccionador).ejecutar().get();
} catch (Exception ex) {
throw new JUnitException("Error al ejecutar pruebas para el motor "
+ getId(), ex);
}
}
Por defecto se utiliza SameThreadHierarchicalTestExecutorService, que ejecuta todas las tareas en el hilo invocador. El EjecutorJerarquico construye un árbol de tareas (NodeTestTask) y lo somete al servicio de ejecución.
NodeTestTask: la unidad de ejecución
Cada nodo del árbol de descriptores se envuelve en un NodeTestTask que implementa la interfaz TestTask. La ejecución de cada tarea sigue un esquema de tres fases:
@Override
public void ejecutar() {
try {
coleccionadorExcepciones = contextoTarea.getColeccionadorExcepcionesFactory().crear();
// Fase 1: Preparar el contexto
preparar();
if (coleccionadorExcepciones.estaVacio()) {
verificarSiDebeOmitirse();
}
if (coleccionadorExcepciones.estaVacio() && !resultadoOmision.seOmite()) {
// Fase 2: Ejecución recursiva
ejecutarRecursivamente();
}
if (contexto != null) {
// Fase 3: Limpieza de recursos
limpiar();
}
reportarFinalizacion();
} finally {
contexto = null;
}
}
Fase 1: Preparación
El método preparar delega en el nodo (Node) correspondiente al descriptor. Cada nivel jerárquico implementa su propia lógica de preparación. El descriptor del motor (JupiterEngineDescriptor) crea un nuevo contexto con un registro de extensiones por defecto:
public JupiterEngineExecutionContext preparar(JupiterEngineExecutionContext ctx) {
MutableExtensionRegistry registro = MutableExtensionRegistry
.crearRegistroConExtensionesPorDefecto(ctx.getConfiguration());
ExtensionContext extCtx = new JupiterEngineExtensionContext(
ctx.getExecutionListener(), this, ctx.getConfiguration());
return ctx.extend()
.withExtensionRegistry(registro)
.withExtensionContext(extCtx)
.build();
}
Fase 2: Ejecución recursiva
El método ejecutarRecursivamente sigue el patrón visitor para recorrer el árbol de descriptores de forma descendente: motor → clase → método:
private void ejecutarRecursivamente() {
coleccionadorExcepciones.ejecutar(() -> {
nodo.around(contexto, ctx -> {
contexto = ctx;
coleccionadorExcepciones.ejecutar(() -> {
List<NodeTestTask<C>> hijos = descriptor.getChildren().stream()
.map(desc -> new NodeTestTask<C>(contextoTarea, desc))
.collect(Collectors.toCollection(ArrayList::new));
contexto = nodo.before(contexto);
EjecutorDinamico ejecutorDinamico = new EjecutorDinamicoPorDefecto();
contexto = nodo.execute(contexto, ejecutorDinamico);
if (!hijos.isEmpty()) {
hijos.forEach(hijo -> hijo.establecerContextoPadre(contexto));
contextoTarea.getServicioEjecucion().invocarTodos(hijos);
}
coleccionadorExcepciones.ejecutar(ejecutorDinamico::esperarFinalizacion);
});
coleccionadorExcepciones.ejecutar(() -> nodo.after(contexto));
});
});
}
La recursión continúa hasta llegar a un descriptor hoja (normalmente TestMethodTestDescriptor) cuyo conjunto de hijos está vacío.
Ejecución a nivel de clase: ClassTestDescriptor
La preparación de una clase de prueba (ClassBasedTestDescriptor) incluye tareas críticas para la integración con Spring:
public JupiterEngineExecutionContext preparar(JupiterEngineExecutionContext ctx) {
MutableExtensionRegistry registro = registrarExtensionesDesdeAnotacionExtendWith(
ctx.getExtensionRegistry(), estaClaseDePrueba);
registrarExtensionesDesdeCampos(registro, estaClaseDePrueba, null);
this.fabricaInstancias = resolverFabricaInstancias(registro);
registrarAdaptadoresBeforeEach(registro);
registrarAdaptadoresAfterEach(registro);
ThrowableCollector coleccionador = crearColeccionadorExcepciones();
ClassExtensionContext extCtx = new ClassExtensionContext(
ctx.getExtensionContext(), ctx.getExecutionListener(),
this, cicloVida, ctx.getConfiguration(), coleccionador);
this.metodosBeforeAll = buscarMetodosBeforeAll(estaClaseDePrueba,
cicloVida == Lifecycle.PER_METHOD);
this.metodosAfterAll = buscarMetodosAfterAll(estaClaseDePrueba,
cicloVida == Lifecycle.PER_METHOD);
return ctx.extend()
.withTestInstancesProvider(proveedorInstancias(ctx, extCtx))
.withExtensionRegistry(registro)
.withExtensionContext(extCtx)
.withThrowableCollector(coleccionador)
.build();
}
En la fase before, se invocan los callbacks de BeforeAllCallback. Es aquí donde SpringExtension inicializa el TestContextManager, punto de entrada al Spring TestContext Framework:
public JupiterEngineExecutionContext before(JupiterEngineExecutionContext ctx) {
ThrowableCollector coleccionador = ctx.getThrowableCollector();
if (coleccionador.estaVacio()) {
ctx.marcarBeforeAllCallbacksEjecutados(true);
invocarCallbacksBeforeAll(ctx);
if (coleccionador.estaVacio()) {
ctx.marcarMetodosBeforeAllEjecutados(true);
invocarMetodosBeforeAll(ctx);
}
}
return ctx;
}
private void invocarCallbacksBeforeAll(JupiterEngineExecutionContext ctx) {
ExtensionRegistry registro = ctx.getExtensionRegistry();
for (BeforeAllCallback callback : registro.getExtensions(BeforeAllCallback.class)) {
coleccionador.ejecutar(() -> callback.beforeAll(extensionContext));
if (coleccionador.noEstaVacio()) break;
}
}
Dentro de SpringExtension.beforeAll:
public void beforeAll(ExtensionContext contexto) throws Exception {
obtenerTestContextManager(contexto).beforeTestClass();
}
Este método recupera (o crea) un TestContextManager asociado a la clase de prueba a través del Store del ExtensionContext, y dispara los TestExecutionListener de Spring registrados, ejecutando sus callbacks beforeTestClass.
La interfaz Node
La interfaz Node define el contrato que cada nivel del árbol debe cumplir durante la ejecución jerárquica:
public interface Node<C> {
default C preparar(C contexto) throws Exception {
return contexto;
}
default void limpiar(C contexto) throws Exception { }
default SkipResult debeOmitirse(C contexto) throws Exception {
return SkipResult.noOmitir();
}
default C before(C contexto) throws Exception {
return contexto;
}
default C execute(C contexto, DynamicTestExecutor ejecutor) throws Exception {
return contexto;
}
default void after(C contexto) throws Exception { }
default void around(C contexto, Invocation<C> invocacion) throws Exception {
invocacion.invoke(contexto);
}
default void nodoOmitido(C contexto, TestDescriptor descriptor,
SkipResult resultado) { }
default void nodoFinalizado(C contexto, TestDescriptor descriptor,
TestExecutionResult resultado) { }
}
Cada implementación de TestDescriptor sobreescribe los métodos que necesita. Los descriptores contenedor (motor, clase) implementan before y after para gestionar el ciclo de vida de sus hijos, mientras que los descriptores hoja (método) implementan execute con la lógica real de invocación del método de prueba.
Integración con Spring TestContext Framework
La integración entre JUnit 5 y Spring se produce a través de SpringExtension, que implementa múltiples interfaces de extensión de JUnit 5:
BeforeAllCallback: Inicializa elTestContextManagera nivel de clase.BeforeEachCallback: Configura el contexto de Spring y crea la instancia de prueba inyectando dependencias.AfterEachCallback: Ejecuta las tareas de limpieza después de cada método.AfterAllCallback: Destruye el contexto de aplicación al finalizar todos los métodos de la clase.
El TestContextManager actúa como puente entre JUnit 5 y el cotnenedor de Spring, gestionando la creación del ApplicationContext, la inyección de dependencias en las instancias de prueba y la notificación a los TestExecutionListener de Spring en cada fase del ciclo de vida.
Resumen del flujo completo
- La IDE construye un
LauncherDiscoveryRequestcon selectores y filtros. DefaultLauncheritera sobre los motores registrados y delega el descubrimiento.- Cada motor resuelve los selectores recursivamente:
ClasspathRootSelector→ClassSelector→MethodSelector. - Se construye un árbol de
TestDescriptorinterconectados. - El launcher construye un
TestPlannavegable y lo envuelve enInternalTestPlan. - Se ejecuta cada motor creando
NodeTestTaskpor descriptor. - La ejecución recursiva visita motor → clase → método, invocando
before,executeyafteren cada nivel. - Las extensiones (como
SpringExtension) se integran en los puntos de extensión definidos por la interfazNode.