Skip to content

Commit

Permalink
Testing: allow bytecode transformations in QuarkusComponentTest
Browse files Browse the repository at this point in the history
This commit refactors `QuarkusComponentTestExtension` to do the same
class loader dance `QuarkusTestExtension` does. The test instance
is created in a new, per-test class loader, and all test method
invocations are forwarded to it. The new class loader is dropped
at the end of the test.

A stack of test instances is maintained to support `@Nested` tests.

Supporting programmatically or declaratively defined config properties,
programmatically configured mocks or "simplified" interceptor methods
required inventing rather creative protocols for exchanging information
between the two class loaders:

1. For configuration, the map of config properties and the ordinal
   are passed from the original CL to the extra CL verbatim, because
   those are basic Java types that never exist in the extra CL.
2. For mocks, the mock creation functions are registered in both
   CLs under deterministic keys, so registration in one CL always
   matches registration in the other CL.
3. For interceptor methods, we collect all the necessary information
   in the original CL as a data structure composed solely from
   basic Java types, transfer that to the extra CL, and finish
   registration there.
  • Loading branch information
Ladicek committed Aug 24, 2023
1 parent b9b12d0 commit 7aee040
Show file tree
Hide file tree
Showing 9 changed files with 856 additions and 363 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ private BeanProcessor(Builder builder) {
this.injectionPointAnnotationsPredicate = Predicate.not(DotNames.DEPRECATED::equals);
}

public String getName() {
return name;
}

public ContextRegistrar.RegistrationContext registerCustomContexts() {
return beanDeployment.registerCustomContexts(contextRegistrars);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
package io.quarkus.test.component;

import jakarta.inject.Singleton;
import jakarta.enterprise.context.ApplicationScoped;

import org.eclipse.microprofile.config.inject.ConfigProperty;

@Singleton
public class ComponentFoo {
// using normal scope so that client proxy is required, so the class must:
// - not be `final`
// - not have non-`private` `final` methods
// - not have a `private` constructor
// all these rules are deliberately broken to trigger ArC bytecode transformation
@ApplicationScoped
public final class ComponentFoo {

@ConfigProperty(name = "bar", defaultValue = "baz")
String bar;

String ping() {
private ComponentFoo() {
}

final String ping() {
return bar;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
package io.quarkus.test.component;

import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.Deque;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;

import io.quarkus.arc.InterceptorCreator;
Expand All @@ -11,6 +16,12 @@ public class InterceptorMethodCreator implements InterceptorCreator {

static final String CREATE_KEY = "createKey";

private static final AtomicInteger idGenerator = new AtomicInteger();

// filled in the original CL, used to register interceptor methods in the extra CL
private static final Map<String, String[]> interceptorMethods = new HashMap<>();

// filled in the extra CL, used to actually invoke interceptor methods
private static final Map<String, Function<SyntheticCreationalContext<?>, InterceptFunction>> createFunctions = new HashMap<>();

@Override
Expand All @@ -25,12 +36,74 @@ public InterceptFunction create(SyntheticCreationalContext<Object> context) {
throw new IllegalStateException("Create function not found: " + createKey);
}

static void registerCreate(String key, Function<SyntheticCreationalContext<?>, InterceptFunction> create) {
createFunctions.put(key, create);
// called in the original CL, fills `interceptorMethods`
static String preregister(Class<?> testClass, Method interceptorMethod) {
String key = "io_quarkus_test_component_InterceptorMethodCreator_" + idGenerator.incrementAndGet();
String[] descriptor = new String[3 + interceptorMethod.getParameterCount()];
descriptor[0] = testClass.getName();
descriptor[1] = interceptorMethod.getDeclaringClass().getName();
descriptor[2] = interceptorMethod.getName();
for (int i = 0; i < interceptorMethod.getParameterCount(); i++) {
descriptor[3 + i] = interceptorMethod.getParameterTypes()[i].getName();
}
interceptorMethods.put(key, descriptor);
return key;
}

static Map<String, String[]> preregistered() {
return interceptorMethods;
}

// called in the extra CL, fills `createFunctions`
static void register(Map<String, String[]> methods, Deque<?> testInstanceStack) throws ReflectiveOperationException {
for (Map.Entry<String, String[]> entry : methods.entrySet()) {
String key = entry.getKey();
String[] descriptor = entry.getValue();
Class<?> testClass = Class.forName(descriptor[0]);
Class<?> declaringClass = Class.forName(descriptor[1]);
String methodName = descriptor[2];
int params = descriptor.length - 3;
Class<?>[] parameterTypes = new Class<?>[params];
for (int i = 0; i < params; i++) {
parameterTypes[i] = Class.forName(descriptor[3 + i]);
}
Method method = declaringClass.getDeclaredMethod(methodName, parameterTypes);
boolean isStatic = Modifier.isStatic(method.getModifiers());

Function<SyntheticCreationalContext<?>, InterceptFunction> fun = ctx -> {
return ic -> {
Object instance = null;
if (!isStatic) {
for (Object testInstanceData : testInstanceStack) {
// the objects on the stack are instances of `TestInstance` in the original CL,
// need to obtain the test instance (which in turn comes from the extra CL) reflectively
Field field = testInstanceData.getClass().getDeclaredField("testInstance");
field.setAccessible(true);
Object testInstance = field.get(testInstanceData);
if (testInstance.getClass().equals(testClass)) {
instance = testInstance;
break;
}
}
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() {
interceptorMethods.clear();
createFunctions.clear();
idGenerator.set(0);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -11,10 +12,11 @@
import io.quarkus.arc.SyntheticCreationalContext;

public class MockBeanCreator implements BeanCreator<Object> {
private static final Logger LOG = Logger.getLogger(MockBeanCreator.class);

static final String CREATE_KEY = "createKey";

private static final Logger LOG = Logger.getLogger(MockBeanCreator.class);
private static final AtomicInteger idGenerator = new AtomicInteger();

private static final Map<String, Function<SyntheticCreationalContext<?>, ?>> createFunctions = new HashMap<>();

Expand All @@ -34,12 +36,15 @@ public Object create(SyntheticCreationalContext<Object> context) {
return Mockito.mock(implementationClass);
}

static void registerCreate(String key, Function<SyntheticCreationalContext<?>, ?> create) {
static String registerCreate(Function<SyntheticCreationalContext<?>, ?> create) {
String key = "io_quarkus_test_component_MockBeanCreator_" + idGenerator.incrementAndGet();
createFunctions.put(key, create);
return key;
}

static void clear() {
createFunctions.clear();
idGenerator.set(0);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,38 +2,94 @@

import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UncheckedIOException;
import java.net.URL;
import java.util.Collections;
import java.util.Enumeration;
import java.util.Map;
import java.util.Objects;

import io.quarkus.arc.ComponentsProvider;
import io.quarkus.arc.ResourceReferenceProvider;

class QuarkusComponentTestClassLoader extends ClassLoader {
static {
ClassLoader.registerAsParallelCapable();
}

private final Map<String, byte[]> localClasses; // generated and transformed classes
private final File componentsProviderFile;
private final File resourceReferenceProviderFile;

public QuarkusComponentTestClassLoader(ClassLoader parent, File componentsProviderFile,
File resourceReferenceProviderFile) {
public QuarkusComponentTestClassLoader(ClassLoader parent, Map<String, byte[]> localClasses,
File componentsProviderFile) {
super(parent);

this.localClasses = localClasses;
this.componentsProviderFile = Objects.requireNonNull(componentsProviderFile);
this.resourceReferenceProviderFile = resourceReferenceProviderFile;
}

@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 (localClasses != null) {
bytecode = localClasses.get(name);
}
if (bytecode == null && !mustDelegateToParent(name)) {
String path = name.replace('.', '/') + ".class";
try (InputStream in = getParent().getResourceAsStream(path)) {
if (in != null) {
bytecode = in.readAllBytes();
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
if (bytecode != null) {
clazz = defineClass(name, bytecode, 0, bytecode.length);
if (resolve) {
resolveClass(clazz);
}
return clazz;
}

return super.loadClass(name, resolve);
}
}

private static boolean mustDelegateToParent(String name) {
return name.startsWith("java.")
|| name.startsWith("jdk.")
|| name.startsWith("javax.")
|| name.startsWith("sun.")
|| name.startsWith("com.sun.")
|| name.startsWith("org.ietf.jgss.")
|| name.startsWith("org.w3c.")
|| name.startsWith("org.xml.")
|| name.startsWith("org.jcp.xml.")
|| name.equals("io.quarkus.dev.testing.TracingHandler");
}

@Override
public Enumeration<URL> getResources(String name) throws IOException {
if (("META-INF/services/" + ComponentsProvider.class.getName()).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()));
if (componentsProviderFile != null
&& ("META-INF/services/" + ComponentsProvider.class.getName()).equals(name)) {
return Collections.enumeration(Collections.singleton(componentsProviderFile.toURI().toURL()));
}
return super.getResources(name);
}

public static QuarkusComponentTestClassLoader inTCCL() {
ClassLoader tccl = Thread.currentThread().getContextClassLoader();
if (tccl instanceof QuarkusComponentTestClassLoader) {
return (QuarkusComponentTestClassLoader) tccl;
}
throw new IllegalStateException("TCCL is not QuarkusComponentTestClassLoader, the `@RegisterExtension` field"
+ " of type `QuarkusComponentTestExtension` must be `static`");
}
}
Loading

0 comments on commit 7aee040

Please sign in to comment.