From e88c55f992830ff1ebcd5ed92a84760611d39889 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Tue, 16 Sep 2025 08:15:58 +0200 Subject: [PATCH] QuarkusComponentTest: class loading refactoring - resolves #43339 --- .../dev/testing/JunitTestRunner.java | 65 +- integration-tests/devmode/pom.xml | 8 + .../common/FacadeClassLoaderProvider.java | 16 + .../quarkus/test/component/BuildResult.java | 14 + .../test/component/ComponentClassLoader.java | 20 + .../test/component/ComponentContainer.java | 888 ++++++++++++++++++ .../ComponentLauncherSessionListener.java | 43 + .../io/quarkus/test/component/Conditions.java | 22 + .../component/InterceptorMethodCreator.java | 50 +- .../test/component/MockBeanCreator.java | 11 +- ...kusComponentFacadeClassLoaderProvider.java | 81 ++ .../QuarkusComponentTestClassLoader.java | 138 ++- .../QuarkusComponentTestExtension.java | 741 +-------------- ...rkus.test.common.FacadeClassLoaderProvider | 1 + ....platform.launcher.LauncherSessionListener | 1 + .../component/beans/MyOtherComponent.java | 21 + .../DeclarativeDependencyMockingTest.java | 5 + ...rameterInjectionPerClassLifecycleTest.java | 3 + .../junit/classloading/FacadeClassLoader.java | 17 + 19 files changed, 1416 insertions(+), 729 deletions(-) create mode 100644 test-framework/common/src/main/java/io/quarkus/test/common/FacadeClassLoaderProvider.java create mode 100644 test-framework/junit5-component/src/main/java/io/quarkus/test/component/BuildResult.java create mode 100644 test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentClassLoader.java create mode 100644 test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentContainer.java create mode 100644 test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentLauncherSessionListener.java create mode 100644 test-framework/junit5-component/src/main/java/io/quarkus/test/component/Conditions.java create mode 100644 test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentFacadeClassLoaderProvider.java create mode 100644 test-framework/junit5-component/src/main/resources/META-INF/services/io.quarkus.test.common.FacadeClassLoaderProvider create mode 100644 test-framework/junit5-component/src/main/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener create mode 100644 test-framework/junit5-component/src/test/java/io/quarkus/test/component/beans/MyOtherComponent.java diff --git a/core/deployment/src/main/java/io/quarkus/deployment/dev/testing/JunitTestRunner.java b/core/deployment/src/main/java/io/quarkus/deployment/dev/testing/JunitTestRunner.java index 7bb3e7200c584..75d7004f00715 100644 --- a/core/deployment/src/main/java/io/quarkus/deployment/dev/testing/JunitTestRunner.java +++ b/core/deployment/src/main/java/io/quarkus/deployment/dev/testing/JunitTestRunner.java @@ -8,6 +8,7 @@ import java.io.InputStream; import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.nio.file.Files; import java.nio.file.Path; @@ -89,6 +90,9 @@ public class JunitTestRunner { public static final DotName QUARKUS_TEST = DotName.createSimple("io.quarkus.test.junit.QuarkusTest"); public static final DotName QUARKUS_MAIN_TEST = DotName.createSimple("io.quarkus.test.junit.main.QuarkusMainTest"); public static final DotName QUARKUS_INTEGRATION_TEST = DotName.createSimple("io.quarkus.test.junit.QuarkusIntegrationTest"); + public static final DotName QUARKUS_COMPONENT_TEST = DotName.createSimple("io.quarkus.test.component.QuarkusComponentTest"); + public static final DotName QUARKUS_COMPONENT_TEST_EXTENSION = DotName + .createSimple("io.quarkus.test.component.QuarkusComponentTestExtension"); public static final DotName TEST_PROFILE = DotName.createSimple("io.quarkus.test.junit.TestProfile"); public static final DotName TEST = DotName.createSimple(Test.class.getName()); public static final DotName REPEATED_TEST = DotName.createSimple(RepeatedTest.class.getName()); @@ -99,6 +103,7 @@ public class JunitTestRunner { public static final DotName NESTED = DotName.createSimple(Nested.class.getName()); private static final String ARCHUNIT_FIELDSOURCE_FQCN = "com.tngtech.archunit.junit.FieldSource"; private static final String FACADE_CLASS_LOADER_NAME = "io.quarkus.test.junit.classloading.FacadeClassLoader"; + private static final String TEST_DISCOVERY_PROPERTY = "quarkus.continuous-tests-discovery"; private final long runId; private final DevModeContext.ModuleInfo moduleInfo; @@ -568,6 +573,9 @@ private DiscoveryResult discoverTestClasses() { //for now this is out of scope, we are just going to do annotation based discovery //we will need to fix this sooner rather than later though + // Set the system property that is used for QuarkusComponentTest + System.setProperty(TEST_DISCOVERY_PROPERTY, "true"); + if (moduleInfo.getTest().isEmpty()) { return DiscoveryResult.EMPTY; } @@ -613,6 +621,22 @@ private DiscoveryResult discoverTestClasses() { } } + Set quarkusComponentTestClasses = new HashSet<>(); + for (AnnotationInstance a : index.getAnnotations(QUARKUS_COMPONENT_TEST)) { + DotName name = a.target().asClass().name(); + quarkusComponentTestClasses.add(name.toString()); + for (ClassInfo subclass : index.getAllKnownSubclasses(name)) { + quarkusComponentTestClasses.add(subclass.name().toString()); + } + } + for (ClassInfo clazz : index.getKnownUsers(QUARKUS_COMPONENT_TEST_EXTENSION)) { + DotName name = clazz.name(); + quarkusComponentTestClasses.add(name.toString()); + for (ClassInfo subclass : index.getAllKnownSubclasses(name)) { + quarkusComponentTestClasses.add(subclass.name().toString()); + } + } + // The FacadeClassLoader approach of loading test classes with the classloader we will use to run them can only work for `@QuarkusTest` and not main or integration tests // Most logic in the JUnitRunner counts main tests as quarkus tests, so do a (mildly irritating) special pass to get the ones which are strictly @QuarkusTest @@ -676,7 +700,8 @@ private DiscoveryResult discoverTestClasses() { for (DotName testClass : allTestClasses) { String name = testClass.toString(); if (integrationTestClasses.contains(name) - || quarkusTestClasses.contains(name)) { + || quarkusTestClasses.contains(name) + || quarkusComponentTestClasses.contains(name)) { continue; } var enclosing = enclosingClasses.get(testClass); @@ -705,11 +730,14 @@ private DiscoveryResult discoverTestClasses() { // if we didn't find any test classes, let's return early // Make sure you also update the logic for the non-empty case above if you adjust this part if (testType == TestType.ALL) { - if (unitTestClasses.isEmpty() && quarkusTestClasses.isEmpty()) { + if (unitTestClasses.isEmpty() + && quarkusTestClasses.isEmpty() + && quarkusComponentTestClasses.isEmpty()) { return DiscoveryResult.EMPTY; } } else if (testType == TestType.UNIT) { - if (unitTestClasses.isEmpty()) { + if (unitTestClasses.isEmpty() + && quarkusComponentTestClasses.isEmpty()) { return DiscoveryResult.EMPTY; } } else if (quarkusTestClasses.isEmpty()) { @@ -784,8 +812,9 @@ public String apply(Class aClass) { return testProfile.value().asClass().name().toString() + "$$" + aClass.getName(); } })); + QuarkusClassLoader cl = null; - if (!unitTestClasses.isEmpty()) { + if (!unitTestClasses.isEmpty() || !quarkusComponentTestClasses.isEmpty()) { //we need to work the unit test magic //this is a lot more complex //we need to transform the classes to make the tracing magic work @@ -810,6 +839,7 @@ public String apply(Class aClass) { cl = testApplication.createDeploymentClassLoader(); deploymentClassLoader = cl; cl.reset(Collections.emptyMap(), transformedClasses); + for (String i : unitTestClasses) { try { utClasses.add(cl.loadClass(i)); @@ -820,6 +850,30 @@ public String apply(Class aClass) { } } + if (!quarkusComponentTestClasses.isEmpty()) { + try { + // We use the deployment class loader to load the test class + Class qcfcClazz = cl.loadClass("io.quarkus.test.component.QuarkusComponentFacadeClassLoaderProvider"); + Constructor c = qcfcClazz.getConstructor(Class.class, Set.class); + Method getClassLoader = qcfcClazz.getMethod("getClassLoader", String.class, ClassLoader.class); + for (String componentTestClass : quarkusComponentTestClasses) { + try { + Class testClass = cl.loadClass(componentTestClass); + Object ecl = c.newInstance(testClass, classesToTransform); + ClassLoader excl = (ClassLoader) getClassLoader.invoke(ecl, componentTestClass, cl); + utClasses.add(excl.loadClass(componentTestClass)); + } catch (Exception e) { + log.debug(e); + log.warnf("Failed to load component test class %s, it will not be executed this run.", + componentTestClass); + } + } + } catch (ClassNotFoundException | IllegalArgumentException + | SecurityException | NoSuchMethodException e) { + log.warn( + "Failed to load QuarkusComponentFacadeClassLoaderProvider, component test classes will not be executed this run."); + } + } } if (classLoaderToClose != null) { @@ -832,6 +886,9 @@ public String apply(Class aClass) { } } + // Unset the system property that is used for QuarkusComponentTest + System.clearProperty(TEST_DISCOVERY_PROPERTY); + // Make sure you also update the logic for the empty case above if you adjust this part if (testType == TestType.ALL) { //run unit style tests first diff --git a/integration-tests/devmode/pom.xml b/integration-tests/devmode/pom.xml index feed0fc858b04..e6daf5c1d16cf 100644 --- a/integration-tests/devmode/pom.xml +++ b/integration-tests/devmode/pom.xml @@ -89,6 +89,14 @@ quarkus-junit5-internal test + + + io.quarkus + quarkus-junit5 + test + io.quarkus quarkus-junit5-component diff --git a/test-framework/common/src/main/java/io/quarkus/test/common/FacadeClassLoaderProvider.java b/test-framework/common/src/main/java/io/quarkus/test/common/FacadeClassLoaderProvider.java new file mode 100644 index 0000000000000..1037e88265190 --- /dev/null +++ b/test-framework/common/src/main/java/io/quarkus/test/common/FacadeClassLoaderProvider.java @@ -0,0 +1,16 @@ +package io.quarkus.test.common; + +/** + * This internal SPI is used by {@code io.quarkus.test.junit.classloading.FacadeClassLoader} from quarkus-junit5 to extend its + * functionality. + */ +public interface FacadeClassLoaderProvider { + + /** + * @param name The binary name of a class + * @param parent + * @return the class loader or null if no dedicated CL exists for the given class + */ + ClassLoader getClassLoader(String name, ClassLoader parent); + +} diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/BuildResult.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/BuildResult.java new file mode 100644 index 0000000000000..e3a256c5e676b --- /dev/null +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/BuildResult.java @@ -0,0 +1,14 @@ +package io.quarkus.test.component; + +import java.util.Map; +import java.util.Set; + +record BuildResult(Map generatedClasses, + byte[] componentsProvider, + // prefix -> config mapping FQCN + Map> configMappings, + // key -> [testClass, methodName, paramType1, paramType2] + Map interceptorMethods, + Throwable failure) { + +} \ No newline at end of file diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentClassLoader.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentClassLoader.java new file mode 100644 index 0000000000000..42b903b339569 --- /dev/null +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentClassLoader.java @@ -0,0 +1,20 @@ +package io.quarkus.test.component; + +class ComponentClassLoader extends ClassLoader { + + private final QuarkusComponentFacadeClassLoaderProvider cls = new QuarkusComponentFacadeClassLoaderProvider(); + + ComponentClassLoader(ClassLoader parent) { + super(parent); + } + + @Override + public Class loadClass(String name) throws ClassNotFoundException { + ClassLoader cl = cls.getClassLoader(name, getParent()); + if (cl != null) { + return cl.loadClass(name); + } + return getParent().loadClass(name); + } + +} diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentContainer.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentContainer.java new file mode 100644 index 0000000000000..69cc493dd81f9 --- /dev/null +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentContainer.java @@ -0,0 +1,888 @@ +package io.quarkus.test.component; + +import static io.quarkus.commons.classloading.ClassLoaderHelper.fromClassNameToResourceName; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.Executable; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.lang.reflect.ParameterizedType; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.annotation.Priority; +import jakarta.enterprise.inject.Default; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.spi.InjectionPoint; +import jakarta.enterprise.inject.spi.InterceptionType; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import jakarta.interceptor.AroundConstruct; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.InvocationContext; + +import org.eclipse.microprofile.config.Config; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.ClassType; +import org.jboss.jandex.DotName; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.Indexer; +import org.jboss.jandex.Type; +import org.jboss.jandex.Type.Kind; +import org.jboss.logging.Logger; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.RepetitionInfo; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.TestReporter; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +import io.quarkus.arc.All; +import io.quarkus.arc.ComponentsProvider; +import io.quarkus.arc.Unremovable; +import io.quarkus.arc.processor.Annotations; +import io.quarkus.arc.processor.AnnotationsTransformer; +import io.quarkus.arc.processor.BeanArchives; +import io.quarkus.arc.processor.BeanConfigurator; +import io.quarkus.arc.processor.BeanDeployment; +import io.quarkus.arc.processor.BeanDeploymentValidator.ValidationContext; +import io.quarkus.arc.processor.BeanInfo; +import io.quarkus.arc.processor.BeanProcessor; +import io.quarkus.arc.processor.BeanRegistrar; +import io.quarkus.arc.processor.BeanResolver; +import io.quarkus.arc.processor.Beans; +import io.quarkus.arc.processor.BuildExtension.Key; +import io.quarkus.arc.processor.BuiltinBean; +import io.quarkus.arc.processor.BytecodeTransformer; +import io.quarkus.arc.processor.ContextRegistrar; +import io.quarkus.arc.processor.DotNames; +import io.quarkus.arc.processor.InjectionPointInfo; +import io.quarkus.arc.processor.InjectionPointInfo.TypeAndQualifiers; +import io.quarkus.arc.processor.ResourceOutput; +import io.quarkus.arc.processor.Types; +import io.quarkus.dev.testing.TracingHandler; +import io.quarkus.gizmo.Gizmo; +import io.quarkus.test.InjectMock; +import io.smallrye.config.ConfigMapping; + +class ComponentContainer { + + private static final Logger LOG = Logger.getLogger(ComponentContainer.class); + + /** + * Performs the build for the given test class and configuration. + * + * @param testClass + * @param configuration + * @param buildShouldFail + * @return the build result + */ + static BuildResult build(Class testClass, QuarkusComponentTestConfiguration configuration, boolean buildShouldFail, + Set tracedClasses) { + + if (configuration.componentClasses.isEmpty()) { + throw new IllegalStateException("No component classes to test"); + } + long start = System.nanoTime(); + + if (LOG.isDebugEnabled()) { + LOG.debugf("Tested components: \n - %s", + configuration.componentClasses.stream().map(Object::toString).collect(Collectors.joining("\n - "))); + } + + // Build index + IndexView index; + try { + Indexer indexer = new Indexer(); + for (Class componentClass : configuration.componentClasses) { + // Make sure that component hierarchy and all annotations present are indexed + indexComponentClass(indexer, componentClass); + } + indexer.indexClass(ConfigProperty.class); + index = BeanArchives.buildImmutableBeanArchiveIndex(indexer.complete()); + } catch (IOException e) { + throw new IllegalStateException("Failed to create index", e); + } + + ClassLoader testClassLoader = testClass.getClassLoader(); + boolean isContinuousTesting = Conditions.isContinuousTestingDiscovery(); + + IndexView computingIndex = BeanArchives.buildComputingBeanArchiveIndex(testClassLoader, + new ConcurrentHashMap<>(), index); + + Map generatedClasses = new HashMap<>(); + AtomicReference componentsProvider = new AtomicReference<>(); + Map> configMappings = new HashMap<>(); + Map interceptorMethods = new HashMap<>(); + Throwable buildFailure = null; + + try { + // These are populated after BeanProcessor.registerCustomContexts() is called + List qualifiers = new ArrayList<>(); + Set interceptorBindings = new HashSet<>(); + AtomicReference beanResolver = new AtomicReference<>(); + + // Collect all @Inject and @InjectMock test class injection points to define a bean removal exclusion + List injectFields = findInjectFields(testClass, true); + List injectParams = findInjectParams(testClass); + + String beanProcessorName = testClass.getName().replace('.', '_'); + + BeanProcessor.Builder builder = BeanProcessor.builder() + .setName(beanProcessorName) + .addRemovalExclusion(b -> { + // Do not remove beans: + // 1. Annotated with @Unremovable + // 2. Injected in the test class or in a test method parameter + if (b.getTarget().isPresent() + && b.getTarget().get().hasDeclaredAnnotation(Unremovable.class)) { + return true; + } + for (Field injectionPoint : injectFields) { + if (injectionPointMatchesBean(injectionPoint.getGenericType(), injectionPoint, qualifiers, + beanResolver.get(), b)) { + return true; + } + } + for (Parameter param : injectParams) { + if (injectionPointMatchesBean(param.getParameterizedType(), param, qualifiers, beanResolver.get(), + b)) { + return true; + } + } + return false; + }) + .setImmutableBeanArchiveIndex(index) + .setComputingBeanArchiveIndex(computingIndex) + .setRemoveUnusedBeans(true) + .setTransformUnproxyableClasses(true); + + Path generatedClassesDirectory; + + if (isContinuousTesting) { + generatedClassesDirectory = null; + } else { + File testOutputDirectory = getTestOutputDirectory(testClass); + generatedClassesDirectory = testOutputDirectory.getParentFile() + .toPath() + .resolve("generated-classes") + .resolve(beanProcessorName); + Files.createDirectories(generatedClassesDirectory); + } + + builder.setOutput(new ResourceOutput() { + @Override + public void writeResource(Resource resource) throws IOException { + switch (resource.getType()) { + case JAVA_CLASS: + generatedClasses.put(resource.getFullyQualifiedName(), resource.getData()); + if (generatedClassesDirectory != null) { + // debug generated bytecode + resource.writeTo(generatedClassesDirectory.toFile()); + } + break; + case SERVICE_PROVIDER: + if (resource.getName() + .endsWith(ComponentsProvider.class.getName())) { + componentsProvider.set(resource.getData()); + } + break; + default: + throw new IllegalArgumentException("Unsupported resource type: " + resource.getType()); + } + } + }); + + builder.addAnnotationTransformation(AnnotationsTransformer.appliedToField().whenContainsAny(qualifiers) + .whenContainsNone(DotName.createSimple(Inject.class)).thenTransform(t -> t.add(Inject.class))); + + builder.addAnnotationTransformation(new JaxrsSingletonTransformer()); + for (AnnotationsTransformer transformer : configuration.annotationsTransformers) { + builder.addAnnotationTransformation(transformer); + } + + // Register: + // 1) Dummy mock beans for all unsatisfied injection points + // 2) Synthetic beans for Config and @ConfigProperty injection points + builder.addBeanRegistrar(new BeanRegistrar() { + + @Override + public void register(RegistrationContext registrationContext) { + long start = System.nanoTime(); + List beans = registrationContext.beans().collect(); + BeanDeployment beanDeployment = registrationContext.get(Key.DEPLOYMENT); + Set unsatisfiedInjectionPoints = new HashSet<>(); + boolean configInjectionPoint = false; + Set configPropertyInjectionPoints = new HashSet<>(); + DotName configDotName = DotName.createSimple(Config.class); + DotName configPropertyDotName = DotName.createSimple(ConfigProperty.class); + DotName configMappingDotName = DotName.createSimple(ConfigMapping.class); + + // We need to analyze all injection points in order to find + // Config, @ConfigProperty and config mappings injection points + // and all unsatisfied injection points + // to register appropriate synthetic beans + for (InjectionPointInfo injectionPoint : registrationContext.getInjectionPoints()) { + if (injectionPoint.getRequiredType().name().equals(configDotName) + && injectionPoint.hasDefaultedQualifier()) { + configInjectionPoint = true; + continue; + } + if (injectionPoint.getRequiredQualifier(configPropertyDotName) != null) { + configPropertyInjectionPoints.add(new TypeAndQualifiers(injectionPoint.getRequiredType(), + injectionPoint.getRequiredQualifiers())); + continue; + } + BuiltinBean builtin = BuiltinBean.resolve(injectionPoint); + if (builtin != null && builtin != BuiltinBean.INSTANCE && builtin != BuiltinBean.LIST) { + continue; + } + Type requiredType = injectionPoint.getRequiredType(); + Set requiredQualifiers = injectionPoint.getRequiredQualifiers(); + if (builtin == BuiltinBean.LIST) { + // @All List -> Delta + requiredType = requiredType.asParameterizedType().arguments().get(0); + requiredQualifiers = new HashSet<>(requiredQualifiers); + requiredQualifiers.removeIf(q -> q.name().equals(DotNames.ALL)); + if (requiredQualifiers.isEmpty()) { + requiredQualifiers.add(AnnotationInstance.builder(DotNames.DEFAULT).build()); + } + } + if (requiredType.kind() == Kind.CLASS) { + ClassInfo clazz = computingIndex.getClassByName(requiredType.name()); + if (clazz != null && clazz.isInterface()) { + AnnotationInstance configMapping = clazz.declaredAnnotation(configMappingDotName); + if (configMapping != null) { + AnnotationValue prefixValue = configMapping.value("prefix"); + String prefix = prefixValue == null ? "" : prefixValue.asString(); + Set mappingClasses = configMappings.computeIfAbsent(prefix, + k -> new HashSet<>()); + mappingClasses.add(clazz.name().toString()); + } + } + } + if (isSatisfied(requiredType, requiredQualifiers, injectionPoint, beans, beanDeployment, + configuration)) { + continue; + } + if (requiredType.kind() == Kind.PRIMITIVE || requiredType.kind() == Kind.ARRAY) { + throw new IllegalStateException( + "Found an unmockable unsatisfied injection point: " + injectionPoint.getTargetInfo()); + } + unsatisfiedInjectionPoints.add(new TypeAndQualifiers(requiredType, requiredQualifiers)); + LOG.debugf("Unsatisfied injection point found: %s", injectionPoint.getTargetInfo()); + } + + // Make sure that all @InjectMock injection points are also considered unsatisfied dependencies + // This means that a mock is created even if no component declares this dependency + for (Field field : findFields(testClass, List.of(InjectMock.class))) { + Set requiredQualifiers = getQualifiers(field, qualifiers); + if (requiredQualifiers.isEmpty()) { + requiredQualifiers = Set.of(AnnotationInstance.builder(DotNames.DEFAULT).build()); + } + unsatisfiedInjectionPoints + .add(new TypeAndQualifiers(Types.jandexType(field.getGenericType()), requiredQualifiers)); + } + for (Parameter param : findInjectMockParams(testClass)) { + Set requiredQualifiers = getQualifiers(param, qualifiers); + if (requiredQualifiers.isEmpty()) { + requiredQualifiers = Set.of(AnnotationInstance.builder(DotNames.DEFAULT).build()); + } + unsatisfiedInjectionPoints + .add(new TypeAndQualifiers(Types.jandexType(param.getParameterizedType()), requiredQualifiers)); + } + + for (TypeAndQualifiers unsatisfied : unsatisfiedInjectionPoints) { + ClassInfo implementationClass = computingIndex.getClassByName(unsatisfied.type.name()); + BeanConfigurator configurator = registrationContext.configure(implementationClass.name()) + .scope(Singleton.class) + .addType(unsatisfied.type); + unsatisfied.qualifiers.forEach(configurator::addQualifier); + configurator.param("implementationClass", implementationClass) + .creator(MockBeanCreator.class) + .defaultBean() + .identifier("dummy") + .done(); + } + + if (configInjectionPoint) { + registrationContext.configure(Config.class) + .addType(Config.class) + .creator(ConfigBeanCreator.class) + .done(); + } + + if (!configPropertyInjectionPoints.isEmpty()) { + BeanConfigurator configPropertyConfigurator = registrationContext.configure(Object.class) + .identifier("configProperty") + .addQualifier(ConfigProperty.class) + .param("useDefaultConfigProperties", configuration.useDefaultConfigProperties) + .addInjectionPoint(ClassType.create(InjectionPoint.class)) + .creator(ConfigPropertyBeanCreator.class); + for (TypeAndQualifiers configPropertyInjectionPoint : configPropertyInjectionPoints) { + configPropertyConfigurator.addType(configPropertyInjectionPoint.type); + } + configPropertyConfigurator.done(); + } + + if (!configMappings.isEmpty()) { + for (Entry> e : configMappings.entrySet()) { + for (String mapping : e.getValue()) { + DotName mappingName = DotName.createSimple(mapping); + registrationContext.configure(mappingName) + .addType(mappingName) + .creator(ConfigMappingBeanCreator.class) + .param("mappingClass", mapping) + .param("prefix", e.getKey()) + .done(); + } + } + } + + LOG.debugf("Test injection points analyzed in %s ms [found: %s, mocked: %s]", + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start), + registrationContext.getInjectionPoints().size(), + unsatisfiedInjectionPoints.size()); + + // Find all methods annotated with interceptor annotations and register them as synthetic interceptors + processTestInterceptorMethods(testClass, registrationContext, interceptorBindings, interceptorMethods); + } + }); + + // Register mock beans + for (MockBeanConfiguratorImpl mockConfigurator : configuration.mockConfigurators) { + builder.addBeanRegistrar(registrarForMock(testClass, mockConfigurator)); + } + + List bytecodeTransformers = new ArrayList<>(); + + // Process the deployment + BeanProcessor beanProcessor = builder.build(); + try { + Consumer bytecodeTransformerConsumer = bytecodeTransformers::add; + // Populate the list of qualifiers used to simulate quarkus auto injection + ContextRegistrar.RegistrationContext registrationContext = beanProcessor.registerCustomContexts(); + qualifiers.addAll(registrationContext.get(Key.QUALIFIERS).keySet()); + for (DotName binding : registrationContext.get(Key.INTERCEPTOR_BINDINGS).keySet()) { + interceptorBindings.add(binding.toString()); + } + beanResolver.set(registrationContext.get(Key.DEPLOYMENT).getBeanResolver()); + beanProcessor.registerScopes(); + beanProcessor.registerBeans(); + beanProcessor.getBeanDeployment().initBeanByTypeMap(); + beanProcessor.registerSyntheticObservers(); + beanProcessor.initialize(bytecodeTransformerConsumer, Collections.emptyList()); + ValidationContext validationContext = beanProcessor.validate(bytecodeTransformerConsumer); + beanProcessor.processValidationErrors(validationContext); + // Generate resources in parallel + ExecutorService executor = Executors.newCachedThreadPool(); + beanProcessor.generateResources(null, new HashSet<>(), bytecodeTransformerConsumer, true, executor); + executor.shutdown(); + + Map transformedClasses = new HashMap<>(); + Path transformedClassesDirectory = null; + if (!isContinuousTesting) { + File testOutputDirectory = getTestOutputDirectory(testClass); + transformedClassesDirectory = testOutputDirectory.getParentFile().toPath() + .resolve("transformed-classes").resolve(beanProcessorName); + Files.createDirectories(transformedClassesDirectory); + } + + // Make sure the traced classes are transformed in continuous testing + for (String tracedClass : tracedClasses) { + if (tracedClass.startsWith("io.quarkus.test.component")) { + continue; + } + bytecodeTransformers.add(new BytecodeTransformer(tracedClass, (cn, cv) -> new TracingClassVisitor(cv, cn))); + } + + if (!bytecodeTransformers.isEmpty()) { + Map>> map = bytecodeTransformers.stream() + .collect(Collectors.groupingBy(BytecodeTransformer::getClassToTransform, + Collectors.mapping(BytecodeTransformer::getVisitorFunction, Collectors.toList()))); + + for (Map.Entry>> entry : map.entrySet()) { + String className = entry.getKey(); + List> transformations = entry.getValue(); + + String classFileName = className.replace('.', '/') + ".class"; + byte[] bytecode; + try (InputStream in = testClassLoader.getResourceAsStream(classFileName)) { + if (in == null) { + throw new IOException("Resource not found: " + classFileName); + } + bytecode = in.readAllBytes(); + } + ClassReader reader = new ClassReader(bytecode); + ClassWriter writer = new ClassWriter(reader, ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS); + ClassVisitor visitor = writer; + for (BiFunction transformation : transformations) { + visitor = transformation.apply(className, visitor); + } + reader.accept(visitor, 0); + bytecode = writer.toByteArray(); + transformedClasses.put(className, bytecode); + + if (transformedClassesDirectory != null) { + // debug generated bytecode + Path classFile = transformedClassesDirectory.resolve( + classFileName.replace('/', '_').replace('$', '_')); + Files.write(classFile, bytecode); + } + } + } + generatedClasses.putAll(transformedClasses); + + } catch (IOException e) { + throw new IllegalStateException("Error generating resources", e); + } + + } catch (Throwable e) { + if (buildShouldFail) { + buildFailure = e; + } else { + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } else { + throw new RuntimeException(e); + } + } + } finally { + if (buildShouldFail && buildFailure == null) { + throw new AssertionError("The container build was expected to fail!"); + } + } + + LOG.debugf("Component container for %s built in %s ms, using CL: %s", testClass.getSimpleName(), + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start), + ComponentContainer.class.getClassLoader().getClass().getSimpleName()); + return new BuildResult(generatedClasses, componentsProvider.get(), configMappings, interceptorMethods, + buildFailure); + } + + private static BeanRegistrar registrarForMock(Class testClass, MockBeanConfiguratorImpl mock) { + return new BeanRegistrar() { + + @Override + public void register(RegistrationContext context) { + BeanConfigurator configurator = context.configure(mock.beanClass); + configurator.scope(mock.scope); + mock.jandexTypes().forEach(configurator::addType); + mock.jandexQualifiers().forEach(configurator::addQualifier); + if (mock.name != null) { + configurator.name(mock.name); + } + configurator.alternative(mock.alternative); + if (mock.priority != null) { + configurator.priority(mock.priority); + } + if (mock.defaultBean) { + configurator.defaultBean(); + } + String key = MockBeanCreator.registerCreate(testClass.getName(), cast(mock.create)); + configurator.creator(MockBeanCreator.class).param(MockBeanCreator.CREATE_KEY, key).done(); + } + }; + } + + private static void indexComponentClass(Indexer indexer, Class componentClass) { + try { + while (componentClass != null) { + indexer.indexClass(componentClass); + for (Annotation annotation : componentClass.getAnnotations()) { + indexer.indexClass(annotation.annotationType()); + } + for (Field field : componentClass.getDeclaredFields()) { + indexAnnotatedElement(indexer, field); + } + for (Method method : componentClass.getDeclaredMethods()) { + indexAnnotatedElement(indexer, method); + for (Parameter param : method.getParameters()) { + indexAnnotatedElement(indexer, param); + } + } + for (Class iface : componentClass.getInterfaces()) { + indexComponentClass(indexer, iface); + } + componentClass = componentClass.getSuperclass(); + } + } catch (IOException e) { + throw new IllegalStateException("Failed to index:" + componentClass, e); + } + } + + private static void indexAnnotatedElement(Indexer indexer, AnnotatedElement element) throws IOException { + for (Annotation annotation : element.getAnnotations()) { + indexer.indexClass(annotation.annotationType()); + } + } + + private static List findInjectFields(Class testClass, boolean scanEnclosingClasses) { + List> injectAnnotations; + + Class injectSpy = loadInjectSpy(); + if (injectSpy != null) { + injectAnnotations = List.of(Inject.class, InjectMock.class, injectSpy); + } else { + injectAnnotations = List.of(Inject.class, InjectMock.class); + } + + List found = findFields(testClass, injectAnnotations); + if (scanEnclosingClasses) { + Class enclosing = testClass.getEnclosingClass(); + while (enclosing != null) { + // @Nested test class + found.addAll(findFields(enclosing, injectAnnotations)); + enclosing = enclosing.getEnclosingClass(); + } + } + + if (injectSpy != null) { + List injectSpies = found.stream().filter(f -> f.isAnnotationPresent(injectSpy)).toList(); + if (!injectSpies.isEmpty()) { + throw new IllegalStateException("@InjectSpy is not supported by QuarkusComponentTest: " + injectSpies); + } + } + + return found; + } + + @SuppressWarnings("unchecked") + private static Class loadInjectSpy() { + try { + return (Class) Class.forName("io.quarkus.test.junit.mockito.InjectSpy"); + } catch (Throwable e) { + return null; + } + } + + static final Predicate BUILTIN_PARAMETER = new Predicate() { + + @Override + public boolean test(Parameter parameter) { + if (parameter.isAnnotationPresent(TempDir.class)) { + return true; + } + java.lang.reflect.Type type = parameter.getParameterizedType(); + return type.equals(TestInfo.class) || type.equals(RepetitionInfo.class) || type.equals(TestReporter.class); + } + }; + + private static List findInjectParams(Class testClass) { + List testMethods = findMethods(testClass, QuarkusComponentTestExtension::isTestMethod); + List ret = new ArrayList<>(); + for (Method method : testMethods) { + for (Parameter param : method.getParameters()) { + if (BUILTIN_PARAMETER.test(param) + || param.isAnnotationPresent(SkipInject.class)) { + continue; + } + ret.add(param); + } + } + return ret; + } + + private static List findInjectMockParams(Class testClass) { + List testMethods = findMethods(testClass, QuarkusComponentTestExtension::isTestMethod); + List ret = new ArrayList<>(); + for (Method method : testMethods) { + for (Parameter param : method.getParameters()) { + if (param.isAnnotationPresent(InjectMock.class) + && !BUILTIN_PARAMETER.test(param)) { + ret.add(param); + } + } + } + return ret; + } + + static boolean isTestMethod(Executable method) { + return method.isAnnotationPresent(Test.class) + || method.isAnnotationPresent(ParameterizedTest.class) + || method.isAnnotationPresent(RepeatedTest.class); + } + + private static List findFields(Class testClass, List> annotations) { + List fields = new ArrayList<>(); + Class current = testClass; + while (current.getSuperclass() != null) { + for (Field field : current.getDeclaredFields()) { + for (Class annotation : annotations) { + if (field.isAnnotationPresent(annotation)) { + fields.add(field); + break; + } + } + } + current = current.getSuperclass(); + } + return fields; + } + + private static List findMethods(Class testClass, Predicate methodPredicate) { + List methods = new ArrayList<>(); + Class current = testClass; + while (current.getSuperclass() != null) { + for (Method method : current.getDeclaredMethods()) { + if (methodPredicate.test(method)) { + methods.add(method); + } + } + current = current.getSuperclass(); + } + return methods; + } + + private static Set getQualifiers(AnnotatedElement element, Collection qualifiers) { + Set ret = new HashSet<>(); + Annotation[] annotations = element.getDeclaredAnnotations(); + for (Annotation annotation : annotations) { + if (qualifiers.contains(DotName.createSimple(annotation.annotationType()))) { + ret.add(Annotations.jandexAnnotation(annotation)); + } + } + return ret; + } + + private static boolean isListRequiredType(java.lang.reflect.Type type) { + if (type instanceof ParameterizedType) { + final ParameterizedType parameterizedType = (ParameterizedType) type; + return List.class.equals(parameterizedType.getRawType()); + } + return false; + } + + static boolean isListAllInjectionPoint(java.lang.reflect.Type requiredType, Annotation[] qualifiers, + AnnotatedElement annotatedElement) { + if (qualifiers.length > 0 && Arrays.stream(qualifiers).anyMatch(All.Literal.INSTANCE::equals)) { + if (!isListRequiredType(requiredType)) { + throw new IllegalStateException("Invalid injection point type: " + annotatedElement); + } + return true; + } + return false; + } + + static final DotName ALL_NAME = DotName.createSimple(All.class); + + static void adaptListAllQualifiers(Set qualifiers) { + // Remove @All and add @Default if empty + qualifiers.removeIf(a -> a.name().equals(ALL_NAME)); + if (qualifiers.isEmpty()) { + qualifiers.add(AnnotationInstance.builder(Default.class).build()); + } + } + + static java.lang.reflect.Type getFirstActualTypeArgument(java.lang.reflect.Type requiredType) { + if (requiredType instanceof ParameterizedType) { + final ParameterizedType parameterizedType = (ParameterizedType) requiredType; + // List -> String + return parameterizedType.getActualTypeArguments()[0]; + } + return null; + } + + private static boolean injectionPointMatchesBean(java.lang.reflect.Type injectionPointType, + AnnotatedElement annotatedElement, + List allQualifiers, BeanResolver beanResolver, BeanInfo bean) { + Type requiredType; + Set requiredQualifiers = getQualifiers(annotatedElement, allQualifiers); + if (isListAllInjectionPoint(injectionPointType, + Arrays.stream(annotatedElement.getAnnotations()) + .filter(a -> allQualifiers.contains(DotName.createSimple(a.annotationType()))) + .toArray(Annotation[]::new), + annotatedElement)) { + requiredType = Types.jandexType(getFirstActualTypeArgument(injectionPointType)); + adaptListAllQualifiers(requiredQualifiers); + } else if (Instance.class.isAssignableFrom(QuarkusComponentTestConfiguration.getRawType(injectionPointType))) { + requiredType = Types.jandexType(getFirstActualTypeArgument(injectionPointType)); + } else { + requiredType = Types.jandexType(injectionPointType); + } + return beanResolver.matches(bean, requiredType, requiredQualifiers); + } + + private static final String QUARKUS_TEST_COMPONENT_OUTPUT_DIRECTORY = "quarkus.test.component.output-directory"; + + private static File getTestOutputDirectory(Class testClass) { + String outputDirectory = System.getProperty(QUARKUS_TEST_COMPONENT_OUTPUT_DIRECTORY); + File testOutputDirectory; + if (outputDirectory != null) { + testOutputDirectory = new File(outputDirectory); + } else { + // All below string transformations work with _URL encoded_ paths, where e.g. + // a space is replaced with %20. At the end, we feed this back to URI.create + // to make sure the encoding is dealt with properly, so we don't have to do this + // ourselves. Directly passing a URL-encoded string to the File() constructor + // does not work properly. + + // org.acme.Foo -> org/acme/Foo.class + String testClassResourceName = fromClassNameToResourceName(testClass.getName()); + // org/acme/Foo.class -> file:/some/path/to/project/target/test-classes/org/acme/Foo.class + String testPath = testClass.getClassLoader().getResource(testClassResourceName).toString(); + // file:/some/path/to/project/target/test-classes/org/acme/Foo.class -> file:/some/path/to/project/target/test-classes + String testClassesRootPath = testPath.substring(0, testPath.length() - testClassResourceName.length() - 1); + // resolve back to File instance + testOutputDirectory = new File(URI.create(testClassesRootPath)); + } + if (!testOutputDirectory.canWrite()) { + throw new IllegalStateException("Invalid test output directory: " + testOutputDirectory); + } + return testOutputDirectory; + } + + private static boolean isSatisfied(Type requiredType, Set qualifiers, InjectionPointInfo injectionPoint, + Iterable beans, BeanDeployment beanDeployment, QuarkusComponentTestConfiguration configuration) { + for (BeanInfo bean : beans) { + if (Beans.matches(bean, requiredType, qualifiers)) { + LOG.debugf("Injection point %s satisfied by %s", injectionPoint.getTargetInfo(), + bean.toString()); + return true; + } + } + for (MockBeanConfiguratorImpl mock : configuration.mockConfigurators) { + if (mock.matches(beanDeployment.getBeanResolver(), requiredType, qualifiers)) { + LOG.debugf("Injection point %s satisfied by %s", injectionPoint.getTargetInfo(), + mock); + return true; + } + } + return false; + } + + private static void processTestInterceptorMethods(Class testClass, + BeanRegistrar.RegistrationContext registrationContext, Set interceptorBindings, + Map interceptorMethods) { + List> annotations = List.of(AroundInvoke.class, PostConstruct.class, PreDestroy.class, + AroundConstruct.class); + Predicate predicate = m -> { + for (Class annotation : annotations) { + if (m.isAnnotationPresent(annotation)) { + return true; + } + } + return false; + }; + for (Method method : findMethods(testClass, predicate)) { + Set bindings = findBindings(method, interceptorBindings); + if (bindings.isEmpty()) { + throw new IllegalStateException("No bindings declared on a test interceptor method: " + method); + } + validateTestInterceptorMethod(method); + String key = UUID.randomUUID().toString(); + interceptorMethods.put(key, InterceptorMethodCreator.descriptor(method)); + InterceptionType interceptionType; + if (method.isAnnotationPresent(AroundInvoke.class)) { + interceptionType = InterceptionType.AROUND_INVOKE; + } else if (method.isAnnotationPresent(PostConstruct.class)) { + interceptionType = InterceptionType.POST_CONSTRUCT; + } else if (method.isAnnotationPresent(PreDestroy.class)) { + interceptionType = InterceptionType.PRE_DESTROY; + } else if (method.isAnnotationPresent(AroundConstruct.class)) { + interceptionType = InterceptionType.AROUND_CONSTRUCT; + } else { + // This should never happen + throw new IllegalStateException("No interceptor annotation declared on: " + method); + } + int priority = 1; + Priority priorityAnnotation = method.getAnnotation(Priority.class); + if (priorityAnnotation != null) { + priority = priorityAnnotation.value(); + } + registrationContext.configureInterceptor(interceptionType) + .identifier(key) + .priority(priority) + .bindings(bindings.stream().map(Annotations::jandexAnnotation) + .toArray(AnnotationInstance[]::new)) + .param(InterceptorMethodCreator.CREATE_KEY, key) + .creator(InterceptorMethodCreator.class); + } + } + + private static void validateTestInterceptorMethod(Method method) { + Parameter[] params = method.getParameters(); + if (params.length != 1 || !InvocationContext.class.isAssignableFrom(params[0].getType())) { + throw new IllegalStateException("A test interceptor method must declare exactly one InvocationContext parameter:" + + Arrays.toString(params)); + } + + } + + private static Set findBindings(Method method, Set bindings) { + return Arrays.stream(method.getAnnotations()).filter(a -> bindings.contains(a.annotationType().getName())) + .collect(Collectors.toSet()); + } + + @SuppressWarnings("unchecked") + static T cast(Object obj) { + return (T) obj; + } + + public static class TracingClassVisitor extends ClassVisitor { + + private final String className; + + public TracingClassVisitor(ClassVisitor classVisitor, String theClassName) { + super(Gizmo.ASM_API_VERSION, classVisitor); + this.className = theClassName; + } + + @Override + public MethodVisitor visitMethod(int access, String name, String descriptor, + String signature, String[] exceptions) { + MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); + if (name.equals("") || name.equals("")) { + return mv; + } + LOG.debugf("Trace method %s#%s:%s", className, name, descriptor); + return new MethodVisitor(Gizmo.ASM_API_VERSION, mv) { + @Override + public void visitCode() { + super.visitCode(); + visitLdcInsn(className); + visitMethodInsn(Opcodes.INVOKESTATIC, + TracingHandler.class.getName().replace(".", "/"), "trace", + "(Ljava/lang/String;)V", false); + } + }; + } + } +} diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentLauncherSessionListener.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentLauncherSessionListener.java new file mode 100644 index 0000000000000..87f4a0be7c98b --- /dev/null +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/ComponentLauncherSessionListener.java @@ -0,0 +1,43 @@ +package io.quarkus.test.component; + +import org.jboss.logging.Logger; +import org.junit.platform.launcher.LauncherSession; +import org.junit.platform.launcher.LauncherSessionListener; + +public class ComponentLauncherSessionListener implements LauncherSessionListener { + + private static final Logger LOG = Logger.getLogger(ComponentLauncherSessionListener.class); + + private static ComponentClassLoader facadeLoader; + + private static ClassLoader oldCl = null; + + @Override + public void launcherSessionOpened(LauncherSession session) { + if (Conditions.isFacadeLoaderUsed()) { + // Set the TCCL only if FacadeClassLoader is not used + return; + } + LOG.debugf("Set the ComponentFacadeLoader as TCCL"); + ClassLoader currentCl = Thread.currentThread().getContextClassLoader(); + if (currentCl == null + || (currentCl != facadeLoader + && !currentCl.getClass().getName().equals(ComponentClassLoader.class.getName()))) { + oldCl = currentCl; + if (facadeLoader == null) { + facadeLoader = new ComponentClassLoader(currentCl); + } + Thread.currentThread().setContextClassLoader(facadeLoader); + } + } + + @Override + public void launcherSessionClosed(LauncherSession session) { + if (oldCl != null) { + LOG.debugf("Unset the ComponentFacadeLoader TCCL"); + Thread.currentThread().setContextClassLoader(oldCl); + } + QuarkusComponentTestClassLoader.BYTECODE_CACHE.clear(); + } + +} diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/Conditions.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/Conditions.java new file mode 100644 index 0000000000000..ab41328dc8ecf --- /dev/null +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/Conditions.java @@ -0,0 +1,22 @@ +package io.quarkus.test.component; + +final class Conditions { + + private Conditions() { + } + + static boolean isFacadeLoaderUsed() { + try { + ComponentLauncherSessionListener.class.getClassLoader() + .loadClass("io.quarkus.test.junit.classloading.FacadeClassLoader"); + return true; + } catch (ClassNotFoundException e) { + return false; + } + } + + static boolean isContinuousTestingDiscovery() { + return Boolean.parseBoolean(System.getProperty("quarkus.continuous-tests-discovery")); + } + +} diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/InterceptorMethodCreator.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/InterceptorMethodCreator.java index b82442f1a6e56..2b919d57a9bec 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/InterceptorMethodCreator.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/InterceptorMethodCreator.java @@ -1,9 +1,16 @@ package io.quarkus.test.component; +import static io.quarkus.test.component.QuarkusComponentTestExtension.KEY_TEST_INSTANCE; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; import java.util.HashMap; import java.util.Map; +import java.util.Map.Entry; import java.util.function.Function; +import org.junit.jupiter.api.extension.ExtensionContext; + import io.quarkus.arc.InterceptorCreator; import io.quarkus.arc.SyntheticCreationalContext; @@ -25,8 +32,47 @@ public InterceptFunction create(SyntheticCreationalContext context) { throw new IllegalStateException("Create function not found: " + createKey); } - static void registerCreate(String key, Function, InterceptFunction> create) { - createFunctions.put(key, create); + static String[] descriptor(Method interceptorMethod) { + String[] descriptor = new String[2 + interceptorMethod.getParameterCount()]; + descriptor[0] = interceptorMethod.getDeclaringClass().getName(); + descriptor[1] = interceptorMethod.getName(); + for (int i = 0; i < interceptorMethod.getParameterCount(); i++) { + descriptor[2 + i] = interceptorMethod.getParameterTypes()[i].getName(); + } + return descriptor; + } + + static void register(ExtensionContext context, Map interceptorMethods) + throws ClassNotFoundException, NoSuchMethodException, SecurityException { + for (Entry e : interceptorMethods.entrySet()) { + String key = e.getKey(); + String[] descriptor = e.getValue(); + Class declaringClass = Class.forName(descriptor[0]); + String methodName = descriptor[1]; + int params = descriptor.length - 2; + Class[] parameterTypes = new Class[params]; + for (int i = 0; i < params; i++) { + parameterTypes[i] = Class.forName(descriptor[2 + i]); + } + Method method = declaringClass.getDeclaredMethod(methodName, parameterTypes); + boolean isStatic = Modifier.isStatic(method.getModifiers()); + + Function, InterceptFunction> fun = ctx -> { + return ic -> { + Object instance = QuarkusComponentTestExtension.store(context).get(KEY_TEST_INSTANCE); + if (!isStatic) { + if (instance == null) { + throw new IllegalStateException("Test instance not available"); + } + } + if (!method.canAccess(instance)) { + method.setAccessible(true); + } + return method.invoke(instance, ic); + }; + }; + createFunctions.put(key, fun); + } } static void clear() { diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/MockBeanCreator.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/MockBeanCreator.java index 4b888a3d74945..b9edc7e04e07a 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/MockBeanCreator.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/MockBeanCreator.java @@ -2,6 +2,7 @@ import java.util.HashMap; import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import org.jboss.logging.Logger; @@ -18,6 +19,9 @@ public class MockBeanCreator implements BeanCreator { private static final Map, ?>> createFunctions = new HashMap<>(); + // test class -> id generator + private static final Map idGenerators = new HashMap<>(); + @Override public Object create(SyntheticCreationalContext context) { Object createKey = context.getParams().get(CREATE_KEY); @@ -34,12 +38,17 @@ public Object create(SyntheticCreationalContext context) { return Mockito.mock(implementationClass); } - static void registerCreate(String key, Function, ?> create) { + static String registerCreate(String testClass, Function, ?> create) { + AtomicInteger id = idGenerators.computeIfAbsent(testClass, k -> new AtomicInteger()); + String key = testClass + id.incrementAndGet(); + // we rely on deterministic registration which means that mock configurators are processed in the same order during build and also when a test is executed createFunctions.put(key, create); + return key; } static void clear() { createFunctions.clear(); + idGenerators.clear(); } } diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentFacadeClassLoaderProvider.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentFacadeClassLoaderProvider.java new file mode 100644 index 0000000000000..03061ff7a36d3 --- /dev/null +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentFacadeClassLoaderProvider.java @@ -0,0 +1,81 @@ +package io.quarkus.test.component; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Set; + +import org.jboss.logging.Logger; + +import io.quarkus.test.common.FacadeClassLoaderProvider; + +public class QuarkusComponentFacadeClassLoaderProvider implements FacadeClassLoaderProvider { + + private static final Logger LOG = Logger.getLogger(QuarkusComponentFacadeClassLoaderProvider.class); + + // used for continuous testing + private final Class testClass; + private final Set tracedClasses; + + public QuarkusComponentFacadeClassLoaderProvider() { + this(null, Set.of()); + } + + // used in JunitTestRunner + public QuarkusComponentFacadeClassLoaderProvider(Class testClass, Set tracedClasses) { + this.testClass = testClass; + this.tracedClasses = tracedClasses; + } + + @Override + public ClassLoader getClassLoader(String name, ClassLoader parent) { + QuarkusComponentTestConfiguration configuration = null; + boolean buildShouldFail = false; + Class inspectionClass = testClass; + if (inspectionClass == null) { + try { + inspectionClass = QuarkusComponentFacadeClassLoaderProvider.class.getClassLoader().loadClass(name); + } catch (ClassNotFoundException e) { + LOG.warnf("Inspection class not found: %s [CL=%s]", name, + QuarkusComponentFacadeClassLoaderProvider.class.getClassLoader()); + return null; + } + } + + for (Annotation a : inspectionClass.getAnnotations()) { + if (a.annotationType().getName().equals("io.quarkus.test.component.QuarkusComponentTest")) { + configuration = QuarkusComponentTestConfiguration.DEFAULT.update(inspectionClass); + break; + } + } + if (configuration == null) { + for (Field field : inspectionClass.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) + && field.getType().getName().equals("io.quarkus.test.component.QuarkusComponentTestExtension")) { + QuarkusComponentTestExtension extension; + try { + field.setAccessible(true); + extension = (QuarkusComponentTestExtension) field.get(null); + buildShouldFail = extension.isBuildShouldFail(); + configuration = extension.baseConfiguration.update(inspectionClass); + break; + } catch (IllegalArgumentException | IllegalAccessException e) { + throw new IllegalStateException("Unable to read configuration from field: " + field, e); + } + } + } + } + + if (configuration != null) { + try { + LOG.debugf("Created QuarkusComponentTestClassLoader for %s", inspectionClass); + return new QuarkusComponentTestClassLoader(parent, name, + ComponentContainer.build(inspectionClass, configuration, buildShouldFail, tracedClasses)); + } catch (Exception e) { + LOG.errorf("Unable to build container for %s", name); + } + } + return null; + } + +} diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestClassLoader.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestClassLoader.java index 211be976cafbf..5c2b099392548 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestClassLoader.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestClassLoader.java @@ -2,38 +2,142 @@ import java.io.File; import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; import java.net.URL; +import java.nio.file.Files; import java.util.Collections; import java.util.Enumeration; -import java.util.Objects; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; -import io.quarkus.arc.ComponentsProvider; -import io.quarkus.arc.ResourceReferenceProvider; +import org.jboss.logging.Logger; -class QuarkusComponentTestClassLoader extends ClassLoader { +/** + * This class loader is used to load the test class. It's also set as TCCL when a component test is run. + */ +public class QuarkusComponentTestClassLoader extends ClassLoader { - private final File componentsProviderFile; - private final File resourceReferenceProviderFile; + private static final Logger LOG = Logger.getLogger(QuarkusComponentTestClassLoader.class); - public QuarkusComponentTestClassLoader(ClassLoader parent, File componentsProviderFile, - File resourceReferenceProviderFile) { + static { + ClassLoader.registerAsParallelCapable(); + } + + static final ConcurrentMap BYTECODE_CACHE = new ConcurrentHashMap<>(); + + private static final Set PARENT_CL_CLASSES = Set.of( + "io.quarkus.test.component.QuarkusComponentTestClassLoader", + "io.quarkus.dev.testing.TracingHandler"); + + private final String name; + + private final BuildResult buildResult; + + public QuarkusComponentTestClassLoader(ClassLoader parent, String name, BuildResult buildResult) { super(parent); - this.componentsProviderFile = Objects.requireNonNull(componentsProviderFile); - this.resourceReferenceProviderFile = resourceReferenceProviderFile; + this.name = name; + this.buildResult = buildResult; + } + + @Override + public String getName() { + return "QuarkusComponentTestClassLoader: " + name; + } + + public Map> getConfigMappings() { + return buildResult.configMappings(); + } + + public Map getInterceptorMethods() { + return buildResult.interceptorMethods(); + } + + public Throwable getBuildFailure() { + return buildResult.failure(); + } + + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + synchronized (getClassLoadingLock(name)) { + Class clazz = findLoadedClass(name); + if (clazz != null) { + return clazz; + } + byte[] bytecode = null; + if (buildResult.generatedClasses() != null) { + bytecode = buildResult.generatedClasses().get(name); + if (bytecode != null) { + LOG.debugf("Use generated/transformed class for %s", name); + } + } + if (bytecode == null && !mustDelegateToParent(name)) { + bytecode = BYTECODE_CACHE.computeIfAbsent(name, this::loadBytecode).value(); + } + if (bytecode != null) { + LOG.debugf("Define class %s", name); + clazz = defineClass(name, bytecode, 0, bytecode.length); + if (resolve) { + resolveClass(clazz); + } + return clazz; + } + return super.loadClass(name, resolve); + } + } + + private Bytecode loadBytecode(String name) { + byte[] bytecode = null; + String path = name.replace('.', '/') + ".class"; + try (InputStream in = getParent().getResourceAsStream(path)) { + if (in != null) { + LOG.debugf("Loading class %s", name); + bytecode = in.readAllBytes(); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return new Bytecode(bytecode); + } + + private static boolean mustDelegateToParent(String name) { + return name.startsWith("java.") + || name.startsWith("jdk.") + || name.startsWith("javax.") + || name.startsWith("jakarta.") + || name.startsWith("sun.") + || name.startsWith("com.sun.") + || name.startsWith("org.w3c.") + || name.startsWith("org.xml.") + || name.startsWith("org.junit.") + || name.startsWith("org.mockito.") + || PARENT_CL_CLASSES.contains(name); } @Override public Enumeration getResources(String name) throws IOException { - if (("META-INF/services/" + ComponentsProvider.class.getName()).equals(name)) { + LOG.debugf("Get resource: %s", name); + if (("META-INF/services/io.quarkus.arc.ComponentsProvider").equals(name)) { // return URL that points to the correct components provider - return Collections.enumeration(Collections.singleton(componentsProviderFile.toURI() - .toURL())); - } else if (resourceReferenceProviderFile != null - && ("META-INF/services/" + ResourceReferenceProvider.class.getName()).equals(name)) { - return Collections.enumeration(Collections.singleton(resourceReferenceProviderFile.toURI() - .toURL())); + File tempFile = File.createTempFile(this.name + "_ComponentsProvider", null); + tempFile.deleteOnExit(); + Files.write(tempFile.toPath(), buildResult.componentsProvider()); + LOG.debugf("ComponentsProvider tmp file written: %s", tempFile); + return Collections.enumeration(List.of(tempFile.toURI().toURL())); } return super.getResources(name); } + @Override + public String toString() { + return getName(); + } + + record Bytecode(byte[] value) { + + } + } diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java index a11aca47b5927..80a44fefc22b8 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestExtension.java @@ -1,47 +1,33 @@ package io.quarkus.test.component; -import static io.quarkus.commons.classloading.ClassLoaderHelper.fromClassNameToResourceName; import static io.smallrye.config.ConfigMappings.ConfigClass.configClass; -import java.io.File; -import java.io.FileOutputStream; import java.io.IOException; import java.lang.annotation.Annotation; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Executable; import java.lang.reflect.Field; import java.lang.reflect.Method; -import java.lang.reflect.Modifier; import java.lang.reflect.Parameter; import java.lang.reflect.ParameterizedType; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Optional; import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; import java.util.function.Predicate; import java.util.stream.Collectors; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import jakarta.annotation.Priority; import jakarta.enterprise.context.Dependent; import jakarta.enterprise.event.Event; import jakarta.enterprise.inject.AmbiguousResolutionException; @@ -49,27 +35,13 @@ import jakarta.enterprise.inject.Instance; import jakarta.enterprise.inject.spi.Bean; import jakarta.enterprise.inject.spi.BeanManager; -import jakarta.enterprise.inject.spi.InjectionPoint; -import jakarta.enterprise.inject.spi.InterceptionType; import jakarta.inject.Inject; import jakarta.inject.Singleton; -import jakarta.interceptor.AroundConstruct; -import jakarta.interceptor.AroundInvoke; -import jakarta.interceptor.InvocationContext; -import org.eclipse.microprofile.config.Config; -import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.spi.ConfigProviderResolver; import org.eclipse.microprofile.config.spi.Converter; import org.jboss.jandex.AnnotationInstance; -import org.jboss.jandex.AnnotationValue; -import org.jboss.jandex.ClassInfo; -import org.jboss.jandex.ClassType; import org.jboss.jandex.DotName; -import org.jboss.jandex.IndexView; -import org.jboss.jandex.Indexer; -import org.jboss.jandex.Type; -import org.jboss.jandex.Type.Kind; import org.jboss.logging.Logger; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.RepeatedTest; @@ -98,36 +70,12 @@ import io.quarkus.arc.Arc; import io.quarkus.arc.ArcContainer; import io.quarkus.arc.ArcInitConfig; -import io.quarkus.arc.ComponentsProvider; import io.quarkus.arc.InjectableBean; import io.quarkus.arc.InstanceHandle; -import io.quarkus.arc.Unremovable; import io.quarkus.arc.impl.EventBean; import io.quarkus.arc.impl.InstanceImpl; import io.quarkus.arc.impl.Mockable; -import io.quarkus.arc.processor.Annotations; -import io.quarkus.arc.processor.AnnotationsTransformer; -import io.quarkus.arc.processor.BeanArchives; -import io.quarkus.arc.processor.BeanConfigurator; -import io.quarkus.arc.processor.BeanDeployment; -import io.quarkus.arc.processor.BeanDeploymentValidator.ValidationContext; -import io.quarkus.arc.processor.BeanInfo; -import io.quarkus.arc.processor.BeanProcessor; -import io.quarkus.arc.processor.BeanRegistrar; -import io.quarkus.arc.processor.BeanResolver; -import io.quarkus.arc.processor.Beans; -import io.quarkus.arc.processor.BuildExtension.Key; -import io.quarkus.arc.processor.BuiltinBean; -import io.quarkus.arc.processor.BytecodeTransformer; -import io.quarkus.arc.processor.ContextRegistrar; -import io.quarkus.arc.processor.DotNames; -import io.quarkus.arc.processor.InjectionPointInfo; -import io.quarkus.arc.processor.InjectionPointInfo.TypeAndQualifiers; -import io.quarkus.arc.processor.ResourceOutput; -import io.quarkus.arc.processor.Types; -import io.quarkus.bootstrap.classloading.QuarkusClassLoader; import io.quarkus.test.InjectMock; -import io.smallrye.config.ConfigMapping; import io.smallrye.config.ConfigMappings.ConfigClass; import io.smallrye.config.SmallRyeConfig; import io.smallrye.config.SmallRyeConfigBuilder; @@ -191,15 +139,13 @@ public static QuarkusComponentTestExtensionBuilder builder() { private static final String KEY_GENERATED_RESOURCES = "generatedResources"; private static final String KEY_INJECTED_FIELDS = "injectedFields"; private static final String KEY_INJECTED_PARAMS = "injectedParams"; - private static final String KEY_TEST_INSTANCE = "testInstance"; + static final String KEY_TEST_INSTANCE = "testInstance"; private static final String KEY_CONFIG = "config"; private static final String KEY_TEST_CLASS_CONFIG = "testClassConfig"; private static final String KEY_CONFIG_MAPPINGS = "configMappings"; private static final String KEY_CONTAINER_STATE = "containerState"; - private static final String QUARKUS_TEST_COMPONENT_OUTPUT_DIRECTORY = "quarkus.test.component.output-directory"; - - private final QuarkusComponentTestConfiguration baseConfiguration; + final QuarkusComponentTestConfiguration baseConfiguration; private final boolean buildShouldFail; private final AtomicReference buildFailure; @@ -227,6 +173,10 @@ public QuarkusComponentTestExtension(Class... additionalComponentClasses) { this.buildFailure = new AtomicReference<>(); } + boolean isBuildShouldFail() { + return buildShouldFail; + } + Throwable getBuildFailure() { return buildFailure.get(); } @@ -397,7 +347,35 @@ private void buildContainer(ExtensionContext context) { QuarkusComponentTestConfiguration testClassConfiguration = baseConfiguration .update(context.getRequiredTestClass()); store(context).put(KEY_TEST_CLASS_CONFIG, testClassConfiguration); - ClassLoader oldTccl = initArcContainer(context, testClassConfiguration); + + ClassLoader oldTccl = Thread.currentThread().getContextClassLoader(); + Class testClass = context.getRequiredTestClass(); + ClassLoader testCl = testClass.getClassLoader(); + Thread.currentThread().setContextClassLoader(testCl); + + if (testCl instanceof QuarkusComponentTestClassLoader componentCl) { + Map> configMappings = componentCl.getConfigMappings(); + if (!configMappings.isEmpty()) { + Set mappings = new HashSet<>(); + for (Entry> e : configMappings.entrySet()) { + for (String mapping : e.getValue()) { + mappings.add(configClass(ConfigMappingBeanCreator.tryLoad(mapping), e.getKey())); + } + } + store(context).put(KEY_CONFIG_MAPPINGS, mappings); + } + try { + InterceptorMethodCreator.register(context, componentCl.getInterceptorMethods()); + } catch (Exception e) { + throw new IllegalStateException("Unable to register interceptor methods", e); + } + buildFailure.set((Throwable) componentCl.getBuildFailure()); + } + + for (MockBeanConfiguratorImpl mockBeanConfigurator : testClassConfiguration.mockConfigurators) { + MockBeanCreator.registerCreate(testClass.getName(), cast(mockBeanConfigurator.create)); + } + if (buildFailure.get() == null) { store(context).put(KEY_OLD_TCCL, oldTccl); setContainerState(context, ContainerState.INITIALIZED); @@ -550,36 +528,10 @@ private void startContainer(ExtensionContext context, Lifecycle testInstanceLife setContainerState(context, ContainerState.STARTED); } - private Store store(ExtensionContext context) { + static Store store(ExtensionContext context) { return context.getRoot().getStore(NAMESPACE); } - private BeanRegistrar registrarForMock(MockBeanConfiguratorImpl mock) { - return new BeanRegistrar() { - - @Override - public void register(RegistrationContext context) { - BeanConfigurator configurator = context.configure(mock.beanClass); - configurator.scope(mock.scope); - mock.jandexTypes().forEach(configurator::addType); - mock.jandexQualifiers().forEach(configurator::addQualifier); - if (mock.name != null) { - configurator.name(mock.name); - } - configurator.alternative(mock.alternative); - if (mock.priority != null) { - configurator.priority(mock.priority); - } - if (mock.defaultBean) { - configurator.defaultBean(); - } - String key = UUID.randomUUID().toString(); - MockBeanCreator.registerCreate(key, cast(mock.create)); - configurator.creator(MockBeanCreator.class).param(MockBeanCreator.CREATE_KEY, key).done(); - } - }; - } - private static Annotation[] getQualifiers(AnnotatedElement element, BeanManager beanManager) { List ret = new ArrayList<>(); Annotation[] annotations = element.getDeclaredAnnotations(); @@ -595,519 +547,6 @@ private static Annotation[] getQualifiers(AnnotatedElement element, BeanManager return ret.toArray(new Annotation[0]); } - private static Set getQualifiers(AnnotatedElement element, Collection qualifiers) { - Set ret = new HashSet<>(); - Annotation[] annotations = element.getDeclaredAnnotations(); - for (Annotation annotation : annotations) { - if (qualifiers.contains(DotName.createSimple(annotation.annotationType()))) { - ret.add(Annotations.jandexAnnotation(annotation)); - } - } - return ret; - } - - private ClassLoader initArcContainer(ExtensionContext extensionContext, QuarkusComponentTestConfiguration configuration) { - if (configuration.componentClasses.isEmpty()) { - throw new IllegalStateException("No component classes to test"); - } - // Make sure Arc is down - try { - Arc.shutdown(); - } catch (Exception e) { - throw new IllegalStateException("An error occured during ArC shutdown: " + e); - } - - if (LOG.isDebugEnabled()) { - LOG.debugf("Tested components: \n - %s", - configuration.componentClasses.stream().map(Object::toString).collect(Collectors.joining("\n - "))); - } - - // Build index - IndexView index; - try { - Indexer indexer = new Indexer(); - for (Class componentClass : configuration.componentClasses) { - // Make sure that component hierarchy and all annotations present are indexed - indexComponentClass(indexer, componentClass); - } - indexer.indexClass(ConfigProperty.class); - index = BeanArchives.buildImmutableBeanArchiveIndex(indexer.complete()); - } catch (IOException e) { - throw new IllegalStateException("Failed to create index", e); - } - - Class testClass = extensionContext.getRequiredTestClass(); - ClassLoader testClassClassLoader = testClass.getClassLoader(); - // The test class is loaded by the QuarkusClassLoader in continuous testing environment - boolean isContinuousTesting = testClassClassLoader instanceof QuarkusClassLoader; - ClassLoader oldTccl = Thread.currentThread().getContextClassLoader(); - - IndexView computingIndex = BeanArchives.buildComputingBeanArchiveIndex(oldTccl, - new ConcurrentHashMap<>(), index); - - try { - - // These are populated after BeanProcessor.registerCustomContexts() is called - List qualifiers = new ArrayList<>(); - Set interceptorBindings = new HashSet<>(); - AtomicReference beanResolver = new AtomicReference<>(); - - // Collect all @Inject and @InjectMock test class injection points to define a bean removal exclusion - List injectFields = findInjectFields(testClass, true); - List injectParams = findInjectParams(testClass); - - BeanProcessor.Builder builder = BeanProcessor.builder() - .setName(testClass.getName().replace('.', '_')) - .addRemovalExclusion(b -> { - // Do not remove beans: - // 1. Annotated with @Unremovable - // 2. Injected in the test class or in a test method parameter - if (b.getTarget().isPresent() - && b.getTarget().get().hasDeclaredAnnotation(Unremovable.class)) { - return true; - } - for (Field injectionPoint : injectFields) { - if (injectionPointMatchesBean(injectionPoint.getGenericType(), injectionPoint, qualifiers, - beanResolver.get(), b)) { - return true; - } - } - for (Parameter param : injectParams) { - if (injectionPointMatchesBean(param.getParameterizedType(), param, qualifiers, beanResolver.get(), - b)) { - return true; - } - } - return false; - }) - .setImmutableBeanArchiveIndex(index) - .setComputingBeanArchiveIndex(computingIndex) - .setRemoveUnusedBeans(true); - - // We need collect all generated resources so that we can remove them after the test - // NOTE: previously we kept the generated framework classes (to speedup subsequent test runs) but that breaks the existing @QuarkusTests - Set generatedResources; - - // E.g. target/generated-arc-sources/org/acme/ComponentsProvider - File componentsProviderFile = getComponentsProviderFile(testClass); - - if (isContinuousTesting) { - generatedResources = Set.of(); - Map classes = new HashMap<>(); - builder.setOutput(new ResourceOutput() { - @Override - public void writeResource(Resource resource) throws IOException { - switch (resource.getType()) { - case JAVA_CLASS: - classes.put(resource.getName() + ".class", resource.getData()); - ((QuarkusClassLoader) testClass.getClassLoader()).reset(classes, Map.of()); - break; - case SERVICE_PROVIDER: - if (resource.getName() - .endsWith(ComponentsProvider.class.getName())) { - componentsProviderFile.getParentFile() - .mkdirs(); - try (FileOutputStream out = new FileOutputStream(componentsProviderFile)) { - out.write(resource.getData()); - } - } - break; - default: - throw new IllegalArgumentException("Unsupported resource type: " + resource.getType()); - } - } - }); - } else { - generatedResources = new HashSet<>(); - File testOutputDirectory = getTestOutputDirectory(testClass); - builder.setOutput(new ResourceOutput() { - @Override - public void writeResource(Resource resource) throws IOException { - switch (resource.getType()) { - case JAVA_CLASS: - generatedResources.add(resource.writeTo(testOutputDirectory).toPath()); - break; - case SERVICE_PROVIDER: - if (resource.getName() - .endsWith(ComponentsProvider.class.getName())) { - componentsProviderFile.getParentFile() - .mkdirs(); - try (FileOutputStream out = new FileOutputStream(componentsProviderFile)) { - out.write(resource.getData()); - } - } - break; - default: - throw new IllegalArgumentException("Unsupported resource type: " + resource.getType()); - } - } - }); - } - - store(extensionContext).put(KEY_GENERATED_RESOURCES, generatedResources); - - builder.addAnnotationTransformation(AnnotationsTransformer.appliedToField().whenContainsAny(qualifiers) - .whenContainsNone(DotName.createSimple(Inject.class)).thenTransform(t -> t.add(Inject.class))); - - builder.addAnnotationTransformation(new JaxrsSingletonTransformer()); - for (AnnotationsTransformer transformer : configuration.annotationsTransformers) { - builder.addAnnotationTransformation(transformer); - } - - // Register: - // 1) Dummy mock beans for all unsatisfied injection points - // 2) Synthetic beans for Config and @ConfigProperty injection points - builder.addBeanRegistrar(new BeanRegistrar() { - - @Override - public void register(RegistrationContext registrationContext) { - long start = System.nanoTime(); - List beans = registrationContext.beans().collect(); - BeanDeployment beanDeployment = registrationContext.get(Key.DEPLOYMENT); - Set unsatisfiedInjectionPoints = new HashSet<>(); - boolean configInjectionPoint = false; - Set configPropertyInjectionPoints = new HashSet<>(); - Map> prefixToConfigMappings = new HashMap<>(); - DotName configDotName = DotName.createSimple(Config.class); - DotName configPropertyDotName = DotName.createSimple(ConfigProperty.class); - DotName configMappingDotName = DotName.createSimple(ConfigMapping.class); - - // We need to analyze all injection points in order to find - // Config, @ConfigProperty and config mappings injection points - // and all unsatisfied injection points - // to register appropriate synthetic beans - for (InjectionPointInfo injectionPoint : registrationContext.getInjectionPoints()) { - if (injectionPoint.getRequiredType().name().equals(configDotName) - && injectionPoint.hasDefaultedQualifier()) { - configInjectionPoint = true; - continue; - } - if (injectionPoint.getRequiredQualifier(configPropertyDotName) != null) { - configPropertyInjectionPoints.add(new TypeAndQualifiers(injectionPoint.getRequiredType(), - injectionPoint.getRequiredQualifiers())); - continue; - } - BuiltinBean builtin = BuiltinBean.resolve(injectionPoint); - if (builtin != null && builtin != BuiltinBean.INSTANCE && builtin != BuiltinBean.LIST) { - continue; - } - Type requiredType = injectionPoint.getRequiredType(); - Set requiredQualifiers = injectionPoint.getRequiredQualifiers(); - if (builtin == BuiltinBean.LIST) { - // @All List -> Delta - requiredType = requiredType.asParameterizedType().arguments().get(0); - requiredQualifiers = new HashSet<>(requiredQualifiers); - requiredQualifiers.removeIf(q -> q.name().equals(DotNames.ALL)); - if (requiredQualifiers.isEmpty()) { - requiredQualifiers.add(AnnotationInstance.builder(DotNames.DEFAULT).build()); - } - } - if (requiredType.kind() == Kind.CLASS) { - ClassInfo clazz = computingIndex.getClassByName(requiredType.name()); - if (clazz != null && clazz.isInterface()) { - AnnotationInstance configMapping = clazz.declaredAnnotation(configMappingDotName); - if (configMapping != null) { - AnnotationValue prefixValue = configMapping.value("prefix"); - String prefix = prefixValue == null ? "" : prefixValue.asString(); - Set mappingClasses = prefixToConfigMappings.computeIfAbsent(prefix, - k -> new HashSet<>()); - mappingClasses.add(clazz.name().toString()); - } - } - } - if (isSatisfied(requiredType, requiredQualifiers, injectionPoint, beans, beanDeployment, - configuration)) { - continue; - } - if (requiredType.kind() == Kind.PRIMITIVE || requiredType.kind() == Kind.ARRAY) { - throw new IllegalStateException( - "Found an unmockable unsatisfied injection point: " + injectionPoint.getTargetInfo()); - } - unsatisfiedInjectionPoints.add(new TypeAndQualifiers(requiredType, requiredQualifiers)); - LOG.debugf("Unsatisfied injection point found: %s", injectionPoint.getTargetInfo()); - } - - // Make sure that all @InjectMock injection points are also considered unsatisfied dependencies - // This means that a mock is created even if no component declares this dependency - for (Field field : findFields(testClass, List.of(InjectMock.class))) { - Set requiredQualifiers = getQualifiers(field, qualifiers); - if (requiredQualifiers.isEmpty()) { - requiredQualifiers = Set.of(AnnotationInstance.builder(DotNames.DEFAULT).build()); - } - TypeAndQualifiers typeAndQualifiers = new TypeAndQualifiers(Types.jandexType(field.getGenericType()), - requiredQualifiers); - if (BuiltinBean.resolve(InjectionPointInfo.fromSyntheticInjectionPoint(typeAndQualifiers)) != null) { - continue; - } - unsatisfiedInjectionPoints.add(typeAndQualifiers); - } - for (Parameter param : findInjectMockParams(testClass)) { - Set requiredQualifiers = getQualifiers(param, qualifiers); - if (requiredQualifiers.isEmpty()) { - requiredQualifiers = Set.of(AnnotationInstance.builder(DotNames.DEFAULT).build()); - } - TypeAndQualifiers typeAndQualifiers = new TypeAndQualifiers( - Types.jandexType(param.getParameterizedType()), requiredQualifiers); - if (BuiltinBean.resolve(InjectionPointInfo.fromSyntheticInjectionPoint(typeAndQualifiers)) != null) { - continue; - } - unsatisfiedInjectionPoints.add(typeAndQualifiers); - } - - for (TypeAndQualifiers unsatisfied : unsatisfiedInjectionPoints) { - ClassInfo implementationClass = computingIndex.getClassByName(unsatisfied.type.name()); - BeanConfigurator configurator = registrationContext.configure(implementationClass.name()) - .scope(Singleton.class) - .addType(unsatisfied.type); - unsatisfied.qualifiers.forEach(configurator::addQualifier); - configurator.param("implementationClass", implementationClass) - .creator(MockBeanCreator.class) - .defaultBean() - .identifier("dummy") - .done(); - } - - if (configInjectionPoint) { - registrationContext.configure(Config.class) - .addType(Config.class) - .creator(ConfigBeanCreator.class) - .done(); - } - - if (!configPropertyInjectionPoints.isEmpty()) { - BeanConfigurator configPropertyConfigurator = registrationContext.configure(Object.class) - .identifier("configProperty") - .addQualifier(ConfigProperty.class) - .param("useDefaultConfigProperties", configuration.useDefaultConfigProperties) - .addInjectionPoint(ClassType.create(InjectionPoint.class)) - .creator(ConfigPropertyBeanCreator.class); - for (TypeAndQualifiers configPropertyInjectionPoint : configPropertyInjectionPoints) { - configPropertyConfigurator.addType(configPropertyInjectionPoint.type); - } - configPropertyConfigurator.done(); - } - - if (!prefixToConfigMappings.isEmpty()) { - Set configMappings = new HashSet<>(); - for (Entry> e : prefixToConfigMappings.entrySet()) { - for (String mapping : e.getValue()) { - DotName mappingName = DotName.createSimple(mapping); - registrationContext.configure(mappingName) - .addType(mappingName) - .creator(ConfigMappingBeanCreator.class) - .param("mappingClass", mapping) - .param("prefix", e.getKey()) - .done(); - configMappings.add(configClass(ConfigMappingBeanCreator.tryLoad(mapping), e.getKey())); - } - } - store(extensionContext).put(KEY_CONFIG_MAPPINGS, configMappings); - } - - LOG.debugf("Test injection points analyzed in %s ms [found: %s, mocked: %s]", - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start), - registrationContext.getInjectionPoints().size(), - unsatisfiedInjectionPoints.size()); - - // Find all methods annotated with interceptor annotations and register them as synthetic interceptors - processTestInterceptorMethods(testClass, extensionContext, registrationContext, interceptorBindings); - } - }); - - // Register mock beans - for (MockBeanConfiguratorImpl mockConfigurator : configuration.mockConfigurators) { - builder.addBeanRegistrar(registrarForMock(mockConfigurator)); - } - - // Process the deployment - BeanProcessor beanProcessor = builder.build(); - try { - Consumer unsupportedBytecodeTransformer = new Consumer() { - @Override - public void accept(BytecodeTransformer transformer) { - throw new UnsupportedOperationException(); - } - }; - // Populate the list of qualifiers used to simulate quarkus auto injection - ContextRegistrar.RegistrationContext registrationContext = beanProcessor.registerCustomContexts(); - qualifiers.addAll(registrationContext.get(Key.QUALIFIERS).keySet()); - for (DotName binding : registrationContext.get(Key.INTERCEPTOR_BINDINGS).keySet()) { - interceptorBindings.add(binding.toString()); - } - beanResolver.set(registrationContext.get(Key.DEPLOYMENT).getBeanResolver()); - beanProcessor.registerScopes(); - beanProcessor.registerBeans(); - beanProcessor.getBeanDeployment().initBeanByTypeMap(); - beanProcessor.registerSyntheticObservers(); - beanProcessor.initialize(unsupportedBytecodeTransformer, Collections.emptyList()); - ValidationContext validationContext = beanProcessor.validate(unsupportedBytecodeTransformer); - beanProcessor.processValidationErrors(validationContext); - // Generate resources in parallel - ExecutorService executor = Executors.newCachedThreadPool(); - beanProcessor.generateResources(null, new HashSet<>(), unsupportedBytecodeTransformer, true, executor); - executor.shutdown(); - } catch (IOException e) { - throw new IllegalStateException("Error generating resources", e); - } - - // Use a custom ClassLoader to load the generated ComponentsProvider file - // In continuous testing the CL that loaded the test class must be used as the parent CL - QuarkusComponentTestClassLoader testClassLoader = new QuarkusComponentTestClassLoader( - isContinuousTesting ? testClassClassLoader : oldTccl, - componentsProviderFile, - null); - Thread.currentThread().setContextClassLoader(testClassLoader); - - } catch (Throwable e) { - if (buildShouldFail) { - buildFailure.set(e); - } else { - if (e instanceof RuntimeException) { - throw (RuntimeException) e; - } else { - throw new RuntimeException(e); - } - } - } finally { - if (buildShouldFail && buildFailure.get() == null) { - throw new AssertionError("The container build was expected to fail!"); - } - } - return oldTccl; - } - - private void processTestInterceptorMethods(Class testClass, ExtensionContext context, - BeanRegistrar.RegistrationContext registrationContext, Set interceptorBindings) { - List> annotations = List.of(AroundInvoke.class, PostConstruct.class, PreDestroy.class, - AroundConstruct.class); - Predicate predicate = m -> { - for (Class annotation : annotations) { - if (m.isAnnotationPresent(annotation)) { - return true; - } - } - return false; - }; - for (Method method : findMethods(testClass, predicate)) { - Set bindings = findBindings(method, interceptorBindings); - if (bindings.isEmpty()) { - throw new IllegalStateException("No bindings declared on a test interceptor method: " + method); - } - validateTestInterceptorMethod(method); - String key = UUID.randomUUID().toString(); - InterceptorMethodCreator.registerCreate(key, ctx -> { - return ic -> { - Object instance = null; - if (!Modifier.isStatic(method.getModifiers())) { - Object testInstance = store(context).get(KEY_TEST_INSTANCE); - if (testInstance == null) { - throw new IllegalStateException("Test instance not available"); - } - instance = testInstance; - if (!method.canAccess(instance)) { - method.setAccessible(true); - } - } - return method.invoke(instance, ic); - }; - }); - InterceptionType interceptionType; - if (method.isAnnotationPresent(AroundInvoke.class)) { - interceptionType = InterceptionType.AROUND_INVOKE; - } else if (method.isAnnotationPresent(PostConstruct.class)) { - interceptionType = InterceptionType.POST_CONSTRUCT; - } else if (method.isAnnotationPresent(PreDestroy.class)) { - interceptionType = InterceptionType.PRE_DESTROY; - } else if (method.isAnnotationPresent(AroundConstruct.class)) { - interceptionType = InterceptionType.AROUND_CONSTRUCT; - } else { - // This should never happen - throw new IllegalStateException("No interceptor annotation declared on: " + method); - } - int priority = 1; - Priority priorityAnnotation = method.getAnnotation(Priority.class); - if (priorityAnnotation != null) { - priority = priorityAnnotation.value(); - } - registrationContext.configureInterceptor(interceptionType) - .identifier(key) - .priority(priority) - .bindings(bindings.stream().map(Annotations::jandexAnnotation) - .toArray(AnnotationInstance[]::new)) - .param(InterceptorMethodCreator.CREATE_KEY, key) - .creator(InterceptorMethodCreator.class); - } - } - - private void validateTestInterceptorMethod(Method method) { - Parameter[] params = method.getParameters(); - if (params.length != 1 || !InvocationContext.class.isAssignableFrom(params[0].getType())) { - throw new IllegalStateException("A test interceptor method must declare exactly one InvocationContext parameter:" - + Arrays.toString(params)); - } - - } - - private Set findBindings(Method method, Set bindings) { - return Arrays.stream(method.getAnnotations()).filter(a -> bindings.contains(a.annotationType().getName())) - .collect(Collectors.toSet()); - } - - private void indexComponentClass(Indexer indexer, Class componentClass) { - try { - while (componentClass != null) { - indexer.indexClass(componentClass); - for (Annotation annotation : componentClass.getAnnotations()) { - indexer.indexClass(annotation.annotationType()); - } - for (Field field : componentClass.getDeclaredFields()) { - indexAnnotatedElement(indexer, field); - } - for (Method method : componentClass.getDeclaredMethods()) { - indexAnnotatedElement(indexer, method); - for (Parameter param : method.getParameters()) { - indexAnnotatedElement(indexer, param); - } - } - for (Class iface : componentClass.getInterfaces()) { - indexComponentClass(indexer, iface); - } - componentClass = componentClass.getSuperclass(); - } - } catch (IOException e) { - throw new IllegalStateException("Failed to index:" + componentClass, e); - } - } - - private void indexAnnotatedElement(Indexer indexer, AnnotatedElement element) throws IOException { - for (Annotation annotation : element.getAnnotations()) { - indexer.indexClass(annotation.annotationType()); - } - } - - private boolean isSatisfied(Type requiredType, Set qualifiers, InjectionPointInfo injectionPoint, - Iterable beans, BeanDeployment beanDeployment, QuarkusComponentTestConfiguration configuration) { - for (BeanInfo bean : beans) { - if (Beans.matches(bean, requiredType, qualifiers)) { - LOG.debugf("Injection point %s satisfied by %s", injectionPoint.getTargetInfo(), - bean.toString()); - return true; - } - } - for (MockBeanConfiguratorImpl mock : configuration.mockConfigurators) { - if (mock.matches(beanDeployment.getBeanResolver(), requiredType, qualifiers)) { - LOG.debugf("Injection point %s satisfied by %s", injectionPoint.getTargetInfo(), - mock); - return true; - } - } - return false; - } - - private String nameToPath(String name) { - return name.replace('.', File.separatorChar); - } - @SuppressWarnings("unchecked") static T cast(Object obj) { return (T) obj; @@ -1160,35 +599,6 @@ private Class loadInjectSpy() { } } - private List findInjectParams(Class testClass) { - List testMethods = findMethods(testClass, QuarkusComponentTestExtension::isTestMethod); - List ret = new ArrayList<>(); - for (Method method : testMethods) { - for (Parameter param : method.getParameters()) { - if (BUILTIN_PARAMETER.test(param) - || param.isAnnotationPresent(SkipInject.class)) { - continue; - } - ret.add(param); - } - } - return ret; - } - - private List findInjectMockParams(Class testClass) { - List testMethods = findMethods(testClass, QuarkusComponentTestExtension::isTestMethod); - List ret = new ArrayList<>(); - for (Method method : testMethods) { - for (Parameter param : method.getParameters()) { - if (param.isAnnotationPresent(InjectMock.class) - && !BUILTIN_PARAMETER.test(param)) { - ret.add(param); - } - } - } - return ret; - } - static boolean isTestMethod(Executable method) { return method.isAnnotationPresent(Test.class) || method.isAnnotationPresent(ParameterizedTest.class) @@ -1212,20 +622,6 @@ private List findFields(Class testClass, List findMethods(Class testClass, Predicate methodPredicate) { - List methods = new ArrayList<>(); - Class current = testClass; - while (current.getSuperclass() != null) { - for (Method method : current.getDeclaredMethods()) { - if (methodPredicate.test(method)) { - methods.add(method); - } - } - current = current.getSuperclass(); - } - return methods; - } - static class FieldInjector { private final Object testInstance; @@ -1395,69 +791,4 @@ private static boolean isTypeArgumentInstanceHandle(java.lang.reflect.Type type) return false; } - private boolean injectionPointMatchesBean(java.lang.reflect.Type injectionPointType, AnnotatedElement annotatedElement, - List allQualifiers, BeanResolver beanResolver, BeanInfo bean) { - Type requiredType; - Set requiredQualifiers = getQualifiers(annotatedElement, allQualifiers); - if (isListAllInjectionPoint(injectionPointType, - Arrays.stream(annotatedElement.getAnnotations()) - .filter(a -> allQualifiers.contains(DotName.createSimple(a.annotationType()))) - .toArray(Annotation[]::new), - annotatedElement)) { - requiredType = Types.jandexType(getFirstActualTypeArgument(injectionPointType)); - adaptListAllQualifiers(requiredQualifiers); - } else if (Instance.class.isAssignableFrom(QuarkusComponentTestConfiguration.getRawType(injectionPointType))) { - requiredType = Types.jandexType(getFirstActualTypeArgument(injectionPointType)); - } else { - requiredType = Types.jandexType(injectionPointType); - } - return beanResolver.matches(bean, requiredType, requiredQualifiers); - } - - private File getTestOutputDirectory(Class testClass) { - String outputDirectory = System.getProperty(QUARKUS_TEST_COMPONENT_OUTPUT_DIRECTORY); - File testOutputDirectory; - if (outputDirectory != null) { - testOutputDirectory = new File(outputDirectory); - } else { - // All below string transformations work with _URL encoded_ paths, where e.g. - // a space is replaced with %20. At the end, we feed this back to URI.create - // to make sure the encoding is dealt with properly, so we don't have to do this - // ourselves. Directly passing a URL-encoded string to the File() constructor - // does not work properly. - - // org.acme.Foo -> org/acme/Foo.class - String testClassResourceName = fromClassNameToResourceName(testClass.getName()); - // org/acme/Foo.class -> file:/some/path/to/project/target/test-classes/org/acme/Foo.class - String testPath = testClass.getClassLoader().getResource(testClassResourceName).toString(); - // file:/some/path/to/project/target/test-classes/org/acme/Foo.class -> file:/some/path/to/project/target/test-classes - String testClassesRootPath = testPath.substring(0, testPath.length() - testClassResourceName.length() - 1); - // resolve back to File instance - testOutputDirectory = new File(URI.create(testClassesRootPath)); - } - if (!testOutputDirectory.canWrite()) { - throw new IllegalStateException("Invalid test output directory: " + testOutputDirectory); - } - return testOutputDirectory; - } - - private File getComponentsProviderFile(Class testClass) { - File generatedSourcesDirectory; - File targetDir = new File("target"); - if (targetDir.canWrite()) { - // maven build - generatedSourcesDirectory = new File(targetDir, "generated-arc-sources"); - } else { - File buildDir = new File("build"); - if (buildDir.canWrite()) { - // gradle build - generatedSourcesDirectory = new File(buildDir, "generated-arc-sources"); - } else { - generatedSourcesDirectory = new File("quarkus-component-test/generated-arc-sources"); - } - } - return new File(new File(generatedSourcesDirectory, nameToPath(testClass.getPackage().getName())), - ComponentsProvider.class.getSimpleName()); - } - } diff --git a/test-framework/junit5-component/src/main/resources/META-INF/services/io.quarkus.test.common.FacadeClassLoaderProvider b/test-framework/junit5-component/src/main/resources/META-INF/services/io.quarkus.test.common.FacadeClassLoaderProvider new file mode 100644 index 0000000000000..f493644a38452 --- /dev/null +++ b/test-framework/junit5-component/src/main/resources/META-INF/services/io.quarkus.test.common.FacadeClassLoaderProvider @@ -0,0 +1 @@ +io.quarkus.test.component.QuarkusComponentFacadeClassLoaderProvider \ No newline at end of file diff --git a/test-framework/junit5-component/src/main/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener b/test-framework/junit5-component/src/main/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener new file mode 100644 index 0000000000000..30aa575d15a27 --- /dev/null +++ b/test-framework/junit5-component/src/main/resources/META-INF/services/org.junit.platform.launcher.LauncherSessionListener @@ -0,0 +1 @@ +io.quarkus.test.component.ComponentLauncherSessionListener \ No newline at end of file diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/beans/MyOtherComponent.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/beans/MyOtherComponent.java new file mode 100644 index 0000000000000..b609bd3d99bd8 --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/beans/MyOtherComponent.java @@ -0,0 +1,21 @@ +package io.quarkus.test.component.beans; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class MyOtherComponent { + + @Inject + Charlie charlie; + + // not proxyable - needs transformation + private MyOtherComponent() { + + } + + public Object ping() { + return charlie.ping(); + } + +} diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/DeclarativeDependencyMockingTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/DeclarativeDependencyMockingTest.java index 7ff08de26db8f..f4f2151b8de9b 100644 --- a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/DeclarativeDependencyMockingTest.java +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/DeclarativeDependencyMockingTest.java @@ -12,6 +12,7 @@ import io.quarkus.test.component.TestConfigProperty; import io.quarkus.test.component.beans.Charlie; import io.quarkus.test.component.beans.MyComponent; +import io.quarkus.test.component.beans.MyOtherComponent; @QuarkusComponentTest @TestConfigProperty(key = "foo", value = "BAR") @@ -20,6 +21,9 @@ public class DeclarativeDependencyMockingTest { @Inject MyComponent myComponent; + @Inject + MyOtherComponent myOtherComponent; + @InjectMock Charlie charlie; @@ -27,6 +31,7 @@ public class DeclarativeDependencyMockingTest { public void testPing1() { Mockito.when(charlie.ping()).thenReturn("foo"); assertEquals("foo and BAR", myComponent.ping()); + assertEquals("foo", myOtherComponent.ping()); } @Test diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerClassLifecycleTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerClassLifecycleTest.java index af1edfe148e13..de7552261146b 100644 --- a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerClassLifecycleTest.java +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerClassLifecycleTest.java @@ -7,13 +7,16 @@ import jakarta.annotation.PostConstruct; import jakarta.inject.Singleton; +import org.junit.jupiter.api.MethodOrderer.OrderAnnotation; import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.TestMethodOrder; import io.quarkus.test.component.QuarkusComponentTest; +@TestMethodOrder(OrderAnnotation.class) @TestInstance(Lifecycle.PER_CLASS) @QuarkusComponentTest public class ParameterInjectionPerClassLifecycleTest { diff --git a/test-framework/junit5/src/main/java/io/quarkus/test/junit/classloading/FacadeClassLoader.java b/test-framework/junit5/src/main/java/io/quarkus/test/junit/classloading/FacadeClassLoader.java index ddae34cf27405..13d8f67aa0ce2 100644 --- a/test-framework/junit5/src/main/java/io/quarkus/test/junit/classloading/FacadeClassLoader.java +++ b/test-framework/junit5/src/main/java/io/quarkus/test/junit/classloading/FacadeClassLoader.java @@ -14,12 +14,14 @@ import java.net.URLClassLoader; import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.ServiceLoader; import java.util.Set; import org.eclipse.microprofile.config.spi.ConfigProviderResolver; @@ -39,6 +41,7 @@ import io.quarkus.bootstrap.app.StartupAction; import io.quarkus.bootstrap.classloading.QuarkusClassLoader; import io.quarkus.bootstrap.resolver.AppModelResolverException; +import io.quarkus.test.common.FacadeClassLoaderProvider; import io.quarkus.test.junit.AppMakerHelper; import io.quarkus.test.junit.QuarkusIntegrationTest; import io.quarkus.test.junit.QuarkusTestExtension; @@ -115,6 +118,8 @@ public final class FacadeClassLoader extends ClassLoader implements Closeable { private final boolean isContinuousTesting; + private final List facadeClassLoaderProviders; + public FacadeClassLoader(ClassLoader parent) { this(parent, false, null, null, null, System.getProperty("java.class.path")); } @@ -253,6 +258,11 @@ public FacadeClassLoader(ClassLoader parent, boolean isAuxiliaryApplication, Cur throw new RuntimeException(e); } } + + facadeClassLoaderProviders = new ArrayList<>(); + ServiceLoader loader = ServiceLoader.load(FacadeClassLoaderProvider.class, + FacadeClassLoader.class.getClassLoader()); + loader.forEach(facadeClassLoaderProviders::add); } @Override @@ -358,6 +368,13 @@ public Class loadClass(String name) throws ClassNotFoundException { return clazz; } else { + for (FacadeClassLoaderProvider p : facadeClassLoaderProviders) { + ClassLoader cl = p.getClassLoader(name, getParent()); + if (cl != null) { + return cl.loadClass(name); + } + } + return super.loadClass(name); }