Skip to content

Commit ac829ad

Browse files
holly-cumminsaloubyanskyradcortez
committed
Load tests with runtime classloader, including profile support
Co-Authored-By: Alexey Loubyansky <[email protected]> Co-Authored-By: Roberto Cortez <[email protected]>
1 parent d3d1131 commit ac829ad

File tree

51 files changed

+2237
-661
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

51 files changed

+2237
-661
lines changed

core/deployment/src/main/java/io/quarkus/deployment/dev/testing/CurrentTestApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
/**
88
* This class is a bit of a hack, it provides a way to pass in the current curratedApplication into the TestExtension
9+
* TODO It is only needed for QuarkusMainTest, so we may be able to find a better way.
10+
* For example, what about JUnit state?
911
*/
1012
public class CurrentTestApplication implements Consumer<CuratedApplication> {
1113
public static volatile CuratedApplication curatedApplication;

core/deployment/src/main/java/io/quarkus/deployment/dev/testing/JunitTestRunner.java

Lines changed: 113 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22

33
import static io.quarkus.commons.classloading.ClassLoaderHelper.fromClassNameToResourceName;
44

5+
import java.io.Closeable;
6+
import java.io.File;
57
import java.io.IOException;
68
import java.io.InputStream;
9+
import java.lang.reflect.Constructor;
10+
import java.lang.reflect.InvocationTargetException;
711
import java.lang.reflect.Modifier;
812
import java.nio.file.Files;
913
import java.nio.file.Path;
@@ -22,7 +26,6 @@
2226
import java.util.Set;
2327
import java.util.concurrent.LinkedBlockingDeque;
2428
import java.util.concurrent.atomic.AtomicReference;
25-
import java.util.function.Consumer;
2629
import java.util.function.Function;
2730
import java.util.regex.Pattern;
2831
import java.util.stream.Collectors;
@@ -74,6 +77,7 @@
7477
import io.quarkus.deployment.util.IoUtil;
7578
import io.quarkus.dev.console.QuarkusConsole;
7679
import io.quarkus.dev.testing.TracingHandler;
80+
import io.quarkus.logging.Log;
7781
import io.quarkus.util.GlobUtil;
7882

7983
/**
@@ -94,6 +98,7 @@ public class JunitTestRunner {
9498
public static final DotName TESTABLE = DotName.createSimple(Testable.class.getName());
9599
public static final DotName NESTED = DotName.createSimple(Nested.class.getName());
96100
private static final String ARCHUNIT_FIELDSOURCE_FQCN = "com.tngtech.archunit.junit.FieldSource";
101+
public static final String FACADE_CLASS_LOADER_NAME = "io.quarkus.test.junit.classloading.FacadeClassLoader";
97102
private final long runId;
98103
private final DevModeContext.ModuleInfo moduleInfo;
99104
private final CuratedApplication testApplication;
@@ -114,6 +119,12 @@ public class JunitTestRunner {
114119

115120
private volatile boolean testsRunning = false;
116121
private volatile boolean aborted;
122+
private QuarkusClassLoader deploymentClassLoader;
123+
124+
// A stable classloader for loading support classes, which can see more than the CL used to load this class
125+
private static QuarkusClassLoader firstDeploymentClassLoader;
126+
127+
// private static ClassLoader classLoaderForLoadingTests;
117128

118129
public JunitTestRunner(Builder builder) {
119130
this.runId = builder.runId;
@@ -140,12 +151,15 @@ public Runnable prepare() {
140151
long start = System.currentTimeMillis();
141152
ClassLoader old = Thread.currentThread().getContextClassLoader();
142153
QuarkusClassLoader tcl = testApplication.createDeploymentClassLoader();
154+
deploymentClassLoader = tcl;
155+
if (firstDeploymentClassLoader == null) {
156+
firstDeploymentClassLoader = deploymentClassLoader;
157+
}
143158
LogCapturingOutputFilter logHandler = new LogCapturingOutputFilter(testApplication, true, true,
144-
TestSupport.instance().get()::isDisplayTestOutput);
159+
TestSupport.instance()
160+
.get()::isDisplayTestOutput);
161+
// TODO do we want to do this setting of the TCCL? I think it just makes problems?
145162
Thread.currentThread().setContextClassLoader(tcl);
146-
Consumer currentTestAppConsumer = (Consumer) tcl.loadClass(CurrentTestApplication.class.getName())
147-
.getDeclaredConstructor().newInstance();
148-
currentTestAppConsumer.accept(testApplication);
149163

150164
Set<UniqueId> allDiscoveredIds = new HashSet<>();
151165
Set<UniqueId> dynamicIds = new HashSet<>();
@@ -407,7 +421,6 @@ public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry e
407421
}
408422
} finally {
409423
try {
410-
currentTestAppConsumer.accept(null);
411424
TracingHandler.setTracingHandler(null);
412425
QuarkusConsole.removeOutputFilter(logHandler);
413426
Thread.currentThread().setContextClassLoader(old);
@@ -587,7 +600,10 @@ private DiscoveryResult discoverTestClasses() {
587600
Set<String> quarkusTestClasses = new HashSet<>();
588601
for (var a : Arrays.asList(QUARKUS_TEST, QUARKUS_MAIN_TEST)) {
589602
for (AnnotationInstance i : index.getAnnotations(a)) {
590-
DotName name = i.target().asClass().name();
603+
604+
DotName name = i.target()
605+
.asClass()
606+
.name();
591607
quarkusTestClasses.add(name.toString());
592608
for (ClassInfo clazz : index.getAllKnownSubclasses(name)) {
593609
if (!integrationTestClasses.contains(clazz.name().toString())) {
@@ -597,6 +613,37 @@ private DiscoveryResult discoverTestClasses() {
597613
}
598614
}
599615

616+
// 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
617+
// 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
618+
619+
Set<String> quarkusTestClassesForFacadeClassLoader = new HashSet<>();
620+
for (var a : Arrays.asList(QUARKUS_TEST)) {
621+
for (AnnotationInstance i : index.getAnnotations(a)) {
622+
DotName name = i.target()
623+
.asClass()
624+
.name();
625+
quarkusTestClassesForFacadeClassLoader.add(name.toString());
626+
for (ClassInfo clazz : index.getAllKnownSubclasses(name)) {
627+
if (!integrationTestClasses.contains(clazz.name()
628+
.toString())) {
629+
quarkusTestClassesForFacadeClassLoader.add(clazz.name()
630+
.toString());
631+
}
632+
}
633+
}
634+
}
635+
636+
Map<String, String> profiles = new HashMap<>();
637+
638+
for (AnnotationInstance i : index.getAnnotations(TEST_PROFILE)) {
639+
640+
DotName name = i.target()
641+
.asClass()
642+
.name();
643+
// We could do the value as a class, but it wouldn't be in the right classloader
644+
profiles.put(name.toString(), i.value().asString());
645+
}
646+
600647
Set<DotName> allTestAnnotations = collectTestAnnotations(index);
601648
Set<DotName> allTestClasses = new HashSet<>();
602649
Map<DotName, DotName> enclosingClasses = new HashMap<>();
@@ -651,13 +698,55 @@ private DiscoveryResult discoverTestClasses() {
651698

652699
List<Class<?>> itClasses = new ArrayList<>();
653700
List<Class<?>> utClasses = new ArrayList<>();
701+
702+
ClassLoader classLoaderForLoadingTests;
703+
Closeable classLoaderToClose = null;
704+
ClassLoader orig = Thread.currentThread().getContextClassLoader();
705+
try {
706+
// JUnitTestRunner is loaded with an augmentation classloader which does not have visibility of FacadeClassLoader, but the deployment classloader can see it
707+
// We need a consistent classloader or we leak curated applications, so use a static classloader we stashed away
708+
Class fclClazz = firstDeploymentClassLoader.loadClass(FACADE_CLASS_LOADER_NAME);
709+
Constructor constructor = fclClazz.getConstructor(ClassLoader.class, boolean.class, CuratedApplication.class,
710+
Map.class,
711+
Set.class,
712+
String.class);
713+
714+
// Passing in the test classes is necessary because in dev mode getAnnotations() on the class returns an empty array, for some reason (plus it saves rediscovery effort)
715+
String classPath = moduleInfo.getMain()
716+
.getClassesPath() + File.pathSeparator + moduleInfo.getTest().get().getClassesPath();
717+
classLoaderForLoadingTests = (ClassLoader) constructor.newInstance(Thread.currentThread()
718+
.getContextClassLoader(), true, testApplication, profiles, quarkusTestClassesForFacadeClassLoader,
719+
classPath);
720+
// We only want to close classloaders if they're facade loaders we made, so squirrel away an instance to close on this path
721+
classLoaderToClose = (Closeable) classLoaderForLoadingTests;
722+
723+
Thread.currentThread()
724+
.setContextClassLoader(classLoaderForLoadingTests);
725+
} catch (ClassNotFoundException | NoSuchMethodException | IllegalAccessException | InstantiationException
726+
| InvocationTargetException e) {
727+
// This is fine, and usually just means that test-framework/junit5 isn't one of the project dependencies
728+
// In that case, fallback to loading classes as we normally would, using a TCCL
729+
Log.debug(
730+
"Could not load class for FacadeClassLoader. This might be because quarkus-junit5 is not on the project classpath: "
731+
+ e);
732+
Log.debug(e);
733+
classLoaderForLoadingTests = Thread.currentThread()
734+
.getContextClassLoader();
735+
}
736+
654737
for (String i : quarkusTestClasses) {
655738
try {
656-
itClasses.add(Thread.currentThread().getContextClassLoader().loadClass(i));
657-
} catch (ClassNotFoundException e) {
739+
// We could load these classes directly, since we know the profile and we have a handy interception point;
740+
// but we need to signal to the downstream interceptor that it shouldn't interfere with the classloading
741+
// While we're doing that, we may as well share the classloading logic
742+
itClasses.add(classLoaderForLoadingTests.loadClass(i));
743+
} catch (Exception e) {
744+
Log.debug(e);
658745
log.warnf(
659746
"Failed to load test class %s (possibly as it was added after the test run started), it will not be executed this run.",
660747
i);
748+
} finally {
749+
// TODO should we do this? Thread.currentThread().setContextClassLoader(old);
661750
}
662751
}
663752
itClasses.sort(Comparator.comparing(new Function<Class<?>, String>() {
@@ -676,8 +765,9 @@ public String apply(Class<?> aClass) {
676765
//we need to work the unit test magic
677766
//this is a lot more complex
678767
//we need to transform the classes to make the tracing magic work
679-
QuarkusClassLoader deploymentClassLoader = (QuarkusClassLoader) Thread.currentThread().getContextClassLoader();
768+
680769
Set<String> classesToTransform = new HashSet<>(deploymentClassLoader.getReloadableClassNames());
770+
// this won't be the right classloader for some profiles, but that is ok because it's only for vanilla tests
681771
Map<String, byte[]> transformedClasses = new HashMap<>();
682772
for (String i : classesToTransform) {
683773
try {
@@ -694,6 +784,7 @@ public String apply(Class<?> aClass) {
694784
}
695785
}
696786
cl = testApplication.createDeploymentClassLoader();
787+
deploymentClassLoader = cl;
697788
cl.reset(Collections.emptyMap(), transformedClasses);
698789
for (String i : unitTestClasses) {
699790
try {
@@ -706,6 +797,17 @@ public String apply(Class<?> aClass) {
706797
}
707798

708799
}
800+
801+
if (classLoaderToClose != null) {
802+
try {
803+
classLoaderToClose.close();
804+
// Don't leave a closed classloader as the TCCL
805+
Thread.currentThread().setContextClassLoader(orig);
806+
} catch (IOException e) {
807+
throw new RuntimeException(e);
808+
}
809+
}
810+
709811
if (testType == TestType.ALL) {
710812
//run unit style tests first
711813
//before the quarkus tests have started
@@ -806,6 +908,7 @@ public Builder setTestType(TestType testType) {
806908
return this;
807909
}
808910

911+
// TODO we now ignore what gets set here and make our own, how to handle that?
809912
public Builder setTestApplication(CuratedApplication testApplication) {
810913
this.testApplication = testApplication;
811914
return this;

core/deployment/src/main/java/io/quarkus/deployment/dev/testing/TestSupport.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,8 @@ public void init() {
213213
final ApplicationModel testModel = appModelFactory.resolveAppModel().getApplicationModel();
214214
bootstrapConfig.setExistingModel(testModel);
215215

216+
// TODO I don't think we should have both this and AppMakerHelper, doing apparently the same thing?
217+
216218
QuarkusClassLoader.Builder clBuilder = null;
217219
var currentParentFirst = curatedApplication.getApplicationModel().getParentFirst();
218220
for (ResolvedDependency d : testModel.getDependencies()) {

core/deployment/src/main/java/io/quarkus/runner/bootstrap/RunningQuarkusApplicationImpl.java

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,27 @@ public void close() throws Exception {
4141

4242
@Override
4343
public <T> Optional<T> getConfigValue(String key, Class<T> type) {
44-
//the config is in an isolated CL
45-
//we need to extract it via reflection
46-
//this is pretty yuck, but I don't really see a solution
47-
ClassLoader old = Thread.currentThread().getContextClassLoader();
44+
45+
ClassLoader old = Thread.currentThread()
46+
.getContextClassLoader();
4847
try {
49-
Class<?> configProviderClass = classLoader.loadClass(ConfigProvider.class.getName());
50-
Method getConfig = configProviderClass.getMethod("getConfig", ClassLoader.class);
51-
Thread.currentThread().setContextClassLoader(classLoader);
52-
Object config = getConfig.invoke(null, classLoader);
53-
return (Optional<T>) getConfig.getReturnType().getMethod("getOptionalValue", String.class, Class.class)
54-
.invoke(config, key, type);
48+
// we are assuming here that the the classloader has been initialised with some kind of different provider that does not infinite loop.
49+
Thread.currentThread()
50+
.setContextClassLoader(classLoader);
51+
if (classLoader == ConfigProvider.class.getClassLoader()) {
52+
return ConfigProvider.getConfig(classLoader)
53+
.getOptionalValue(key, type);
54+
} else {
55+
//the config is in an isolated CL
56+
//we need to extract it via reflection
57+
//this is pretty yuck, but I don't really see a solution
58+
Class<?> configProviderClass = classLoader.loadClass(ConfigProvider.class.getName());
59+
Method getConfig = configProviderClass.getMethod("getConfig", ClassLoader.class);
60+
Object config = getConfig.invoke(null, classLoader);
61+
return (Optional<T>) getConfig.getReturnType()
62+
.getMethod("getOptionalValue", String.class, Class.class)
63+
.invoke(config, key, type);
64+
}
5565
} catch (Exception e) {
5666
throw new RuntimeException(e);
5767
} finally {
@@ -79,8 +89,14 @@ public Iterable<String> getConfigKeys() {
7989
@Override
8090
public Object instance(Class<?> clazz, Annotation... qualifiers) {
8191
try {
82-
Class<?> actualClass = Class.forName(clazz.getName(), true,
83-
classLoader);
92+
// TODO can we drop the class forname entirely?
93+
Class<?> actualClass;
94+
if (classLoader == clazz.getClassLoader()) {
95+
actualClass = clazz;
96+
} else {
97+
actualClass = Class.forName(clazz.getName(), true, classLoader);
98+
}
99+
84100
Class<?> cdi = classLoader.loadClass("jakarta.enterprise.inject.spi.CDI");
85101
Object instance = cdi.getMethod("current").invoke(null);
86102
Method selectMethod = cdi.getMethod("select", Class.class, Annotation[].class);

core/deployment/src/main/java/io/quarkus/runner/bootstrap/StartupActionImpl.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,12 @@ public StartupActionImpl(CuratedApplication curatedApplication, BuildResult buil
7979
} else {
8080
baseClassLoader.reset(extractGeneratedResources(buildResult, false),
8181
transformedClasses);
82+
// TODO Need to do recreations in JUnitTestRunner for dev mode case
8283
runtimeClassLoader = curatedApplication.createRuntimeClassLoader(
8384
resources, transformedClasses);
8485
}
8586
this.runtimeClassLoader = runtimeClassLoader;
87+
runtimeClassLoader.setStartupAction(this);
8688
}
8789

8890
/**

core/runtime/src/main/java/io/quarkus/runtime/configuration/ConfigRecorder.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ public void releaseConfig(ShutdownContext shutdownContext) {
104104
// While this may seem to duplicate code in IsolatedDevModeMain,
105105
// it actually does not because it operates on a different instance
106106
// of QuarkusConfigFactory from a different classloader.
107+
108+
if (shutdownContext == null) {
109+
throw new RuntimeException(
110+
"Internal errror: shutdownContext is null. This probably happened because Quarkus failed to start properly in an earlier step, or because tests were run on a Quarkus instance that had already been shut down.");
111+
}
107112
shutdownContext.addLastShutdownTask(QuarkusConfigFactory::releaseTCCLConfig);
108113
}
109114
}

extensions/devservices/h2/src/main/java/io/quarkus/devservices/h2/deployment/H2DevServicesProcessor.java

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,19 @@ public void close() throws IOException {
8484
} catch (SQLException t) {
8585
t.printStackTrace();
8686
}
87-
tcpServer.stop();
88-
LOG.info("Dev Services for H2 shut down; server status: " + tcpServer.getStatus());
89-
} else {
90-
LOG.info(
91-
"Dev Services for H2 was NOT shut down as it appears it was down already; server status: "
92-
+ tcpServer.getStatus());
87+
// TODO Yes, this is a port leak
88+
// The good news is that because it's an in-memory database, it will get shut down
89+
// when the JVM stops. Nonetheless, this clearly is not ok, and needs
90+
// a fix so that we do not start databases in the augmentation phase
91+
// TODO remove this when #45786 and #45785 are done
92+
final boolean hackPendingDeferredDevServiceStart = true;
93+
if (!hackPendingDeferredDevServiceStart) {
94+
tcpServer.stop();
95+
LOG.info("Dev Services for H2 shut down; server status: " + tcpServer.getStatus());
96+
97+
}
98+
// End of #45786 and #45785 workaround
99+
93100
}
94101
}
95102
});

independent-projects/bootstrap/core/src/main/java/io/quarkus/bootstrap/app/CuratedApplication.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,7 @@ public synchronized QuarkusClassLoader getOrCreateBaseRuntimeClassLoader() {
252252
quarkusBootstrap.getBaseClassLoader(), false)
253253
.setAssertionsEnabled(quarkusBootstrap.isAssertionsEnabled());
254254
builder.addClassLoaderEventListeners(quarkusBootstrap.getClassLoaderEventListeners());
255+
builder.setCuratedApplication(this);
255256

256257
if (configuredClassLoading.isFlatTestClassPath()) {
257258
//in test mode we have everything in the base class loader
@@ -390,6 +391,7 @@ public QuarkusClassLoader createRuntimeClassLoader(ClassLoader base, Map<String,
390391
+ runtimeClassLoaderCount.getAndIncrement(),
391392
getOrCreateBaseRuntimeClassLoader(), false)
392393
.setAssertionsEnabled(quarkusBootstrap.isAssertionsEnabled())
394+
.setCuratedApplication(this)
393395
.setAggregateParentResources(true);
394396
builder.setTransformedClasses(transformedClasses);
395397

0 commit comments

Comments
 (0)