From 4726d3a2828099f0931dd527c1b6aed2f7182beb Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Wed, 21 Jun 2023 15:57:47 +0200 Subject: [PATCH 1/3] QuarkusComponentTest: convenient way of mocking interceptors - resolves #34086 --- .../arc/processor/InjectionPointInfo.java | 27 ++- .../component/InterceptorMethodCreator.java | 36 ++++ .../test/component/MockBeanCreator.java | 4 +- .../QuarkusComponentTestExtension.java | 175 +++++++++++++++--- .../declarative/InterceptorMethodsTest.java | 110 +++++++++++ 5 files changed, 322 insertions(+), 30 deletions(-) create mode 100644 test-framework/junit5-component/src/main/java/io/quarkus/test/component/InterceptorMethodCreator.java create mode 100644 test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/InterceptorMethodsTest.java diff --git a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InjectionPointInfo.java b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InjectionPointInfo.java index 7bc44dbce45c8..f98d4bda71cd4 100644 --- a/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InjectionPointInfo.java +++ b/independent-projects/arc/processor/src/main/java/io/quarkus/arc/processor/InjectionPointInfo.java @@ -409,7 +409,8 @@ public boolean equals(Object obj) { if (other.qualifiers != null) { return false; } - } else if (!qualifiers.equals(other.qualifiers)) { + } else if (!qualifiersAreEqual(qualifiers, other.qualifiers)) { + // We cannot use AnnotationInstance#equals() as it requires the exact same annotationTarget instance return false; } if (type == null) { @@ -422,6 +423,30 @@ public boolean equals(Object obj) { return true; } + private boolean qualifiersAreEqual(Set q1, Set q2) { + if (q1 == q2) { + return true; + } + if (q1.size() != q2.size()) { + return false; + } + for (AnnotationInstance a1 : q1) { + for (AnnotationInstance a2 : q2) { + if (!annotationsAreEqual(a1, a2)) { + return false; + } + } + } + return true; + } + + private boolean annotationsAreEqual(AnnotationInstance a1, AnnotationInstance a2) { + if (a1 == a2) { + return true; + } + return a1.name().equals(a2.name()) && a1.values().equals(a2.values()); + } + } } 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 new file mode 100644 index 0000000000000..9f0f09b91035c --- /dev/null +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/InterceptorMethodCreator.java @@ -0,0 +1,36 @@ +package io.quarkus.test.component; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import io.quarkus.arc.InterceptorCreator; +import io.quarkus.arc.SyntheticCreationalContext; + +public class InterceptorMethodCreator implements InterceptorCreator { + + static final String CREATE_KEY = "createKey"; + + private static final Map, InterceptFunction>> createFunctions = new HashMap<>(); + + @Override + public InterceptFunction create(SyntheticCreationalContext context) { + Object createKey = context.getParams().get(CREATE_KEY); + if (createKey != null) { + Function, InterceptFunction> createFun = createFunctions.get(createKey.toString()); + if (createFun != null) { + return createFun.apply(context); + } + } + throw new IllegalStateException("Create function not found: " + createKey); + } + + static void registerCreate(String key, Function, InterceptFunction> create) { + createFunctions.put(key, create); + } + + static void clear() { + createFunctions.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 cce7bfaa6e07b..45fcc444509d9 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 @@ -12,13 +12,15 @@ public class MockBeanCreator implements BeanCreator { + static final String CREATE_KEY = "createKey"; + private static final Logger LOG = Logger.getLogger(MockBeanCreator.class); private static final Map, ?>> createFunctions = new HashMap<>(); @Override public Object create(SyntheticCreationalContext context) { - Object createKey = context.getParams().get("createKey"); + Object createKey = context.getParams().get(CREATE_KEY); if (createKey != null) { Function, ?> createFun = createFunctions.get(createKey.toString()); if (createFun != null) { 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 902f8ab857dd8..8734595023487 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 @@ -32,13 +32,19 @@ import java.util.function.Function; import java.util.stream.Collectors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.Dependent; import jakarta.enterprise.event.Event; import jakarta.enterprise.inject.Instance; 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; @@ -145,6 +151,7 @@ public class QuarkusComponentTestExtension private static final String KEY_OLD_CONFIG_PROVIDER_RESOLVER = "oldConfigProviderResolver"; private static final String KEY_GENERATED_RESOURCES = "generatedResources"; private static final String KEY_INJECTED_FIELDS = "injectedFields"; + private static final String KEY_TEST_INSTANCE = "testInstance"; private static final String KEY_CONFIG = "config"; private static final String TARGET_TEST_CLASSES = "target/test-classes"; @@ -232,6 +239,7 @@ public void postProcessTestInstance(Object testInstance, ExtensionContext contex // Inject test class fields context.getRoot().getStore(NAMESPACE).put(KEY_INJECTED_FIELDS, injectFields(context.getRequiredTestClass(), testInstance)); + context.getRoot().getStore(NAMESPACE).put(KEY_TEST_INSTANCE, testInstance); LOG.debugf("postProcessTestInstance: %s ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); } @@ -328,6 +336,7 @@ public void afterAll(ExtensionContext context) throws Exception { } MockBeanCreator.clear(); ConfigBeanCreator.clear(); + InterceptorMethodCreator.clear(); SmallRyeConfig config = context.getRoot().getStore(NAMESPACE).get(KEY_CONFIG, SmallRyeConfig.class); ConfigProviderResolver.instance().releaseConfig(config); @@ -417,7 +426,7 @@ public void register(RegistrationContext context) { } String key = UUID.randomUUID().toString(); MockBeanCreator.registerCreate(key, cast(mock.create)); - configurator.creator(MockBeanCreator.class).param("createKey", key).done(); + configurator.creator(MockBeanCreator.class).param(MockBeanCreator.CREATE_KEY, key).done(); } }; } @@ -447,12 +456,7 @@ private static Set getQualifiers(Field field, Collection> componentClasses) { Class testClass = context.getRequiredTestClass(); // Collect all test class injection points to define a bean removal exclusion - List testClassInjectionPoints = new ArrayList<>(); - for (Field field : testClass.getDeclaredFields()) { - if (field.isAnnotationPresent(Inject.class)) { - testClassInjectionPoints.add(field); - } - } + List testClassInjectionPoints = findInjectFields(testClass); if (componentClasses.isEmpty()) { throw new IllegalStateException("No component classes to test"); @@ -488,6 +492,7 @@ private ClassLoader initArcContainer(ExtensionContext context, Collection qualifiers = new ArrayList<>(); + Set interceptorBindings = new HashSet<>(); AtomicReference beanResolver = new AtomicReference<>(); BeanProcessor.Builder builder = BeanProcessor.builder() @@ -549,7 +554,6 @@ public void writeResource(Resource resource) throws IOException { String testPath = testClass.getClassLoader().getResource(testClass.getName().replace(".", "/") + ".class") .getFile(); int targetClassesIndex = testPath.indexOf(TARGET_TEST_CLASSES); - // NOTE: continuous testing is not supported at the moment if (targetClassesIndex == -1) { throw new IllegalStateException("Invalid test path: " + testPath); } @@ -591,10 +595,10 @@ public void writeResource(Resource resource) throws IOException { builder.addBeanRegistrar(new BeanRegistrar() { @Override - public void register(RegistrationContext context) { + public void register(RegistrationContext registrationContext) { long start = System.nanoTime(); - List beans = context.beans().collect(); - BeanDeployment beanDeployment = context.get(Key.DEPLOYMENT); + List beans = registrationContext.beans().collect(); + BeanDeployment beanDeployment = registrationContext.get(Key.DEPLOYMENT); Set unsatisfiedInjectionPoints = new HashSet<>(); boolean configInjectionPoint = false; Set configPropertyInjectionPoints = new HashSet<>(); @@ -604,7 +608,7 @@ public void register(RegistrationContext context) { // Analyze injection points // - find Config and @ConfigProperty injection points // - find unsatisfied injection points - for (InjectionPointInfo injectionPoint : context.getInjectionPoints()) { + for (InjectionPointInfo injectionPoint : registrationContext.getInjectionPoints()) { BuiltinBean builtin = BuiltinBean.resolve(injectionPoint); if (builtin != null && builtin != BuiltinBean.INSTANCE && builtin != BuiltinBean.LIST) { continue; @@ -620,27 +624,41 @@ public void register(RegistrationContext context) { continue; } Type requiredType = injectionPoint.getRequiredType(); - Set qualifiers = injectionPoint.getRequiredQualifiers(); + Set requiredQualifiers = injectionPoint.getRequiredQualifiers(); if (builtin == BuiltinBean.LIST) { // @All List -> Delta requiredType = requiredType.asParameterizedType().arguments().get(0); - qualifiers = new HashSet<>(qualifiers); - qualifiers.removeIf(q -> q.name().equals(DotNames.ALL)); + requiredQualifiers = new HashSet<>(requiredQualifiers); + requiredQualifiers.removeIf(q -> q.name().equals(DotNames.ALL)); + if (requiredQualifiers.isEmpty()) { + requiredQualifiers.add(AnnotationInstance.builder(DotNames.DEFAULT).build()); + } } - if (isSatisfied(requiredType, qualifiers, injectionPoint, beans, beanDeployment)) { + if (isSatisfied(requiredType, requiredQualifiers, injectionPoint, beans, beanDeployment)) { 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, qualifiers)); + unsatisfiedInjectionPoints.add(new TypeAndQualifiers(requiredType, requiredQualifiers)); LOG.debugf("Unsatisfied injection point found: %s", injectionPoint.getTargetInfo()); } + // Make sure that all @InjectMock fields 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 (TypeAndQualifiers unsatisfied : unsatisfiedInjectionPoints) { ClassInfo implementationClass = computingIndex.getClassByName(unsatisfied.type.name()); - BeanConfigurator configurator = context.configure(implementationClass.name()) + BeanConfigurator configurator = registrationContext.configure(implementationClass.name()) .scope(Singleton.class) .addType(unsatisfied.type); unsatisfied.qualifiers.forEach(configurator::addQualifier); @@ -652,14 +670,14 @@ public void register(RegistrationContext context) { } if (configInjectionPoint) { - context.configure(Config.class) + registrationContext.configure(Config.class) .addType(Config.class) .creator(ConfigBeanCreator.class) .done(); } if (!configPropertyInjectionPoints.isEmpty()) { - BeanConfigurator configPropertyConfigurator = context.configure(Object.class) + BeanConfigurator configPropertyConfigurator = registrationContext.configure(Object.class) .identifier("configProperty") .addQualifier(ConfigProperty.class) .param("useDefaultConfigProperties", useDefaultConfigProperties.get()) @@ -672,8 +690,58 @@ public void register(RegistrationContext context) { } LOG.debugf("Test injection points analyzed in %s ms [found: %s, mocked: %s]", - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start), context.getInjectionPoints().size(), + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start), + registrationContext.getInjectionPoints().size(), unsatisfiedInjectionPoints.size()); + + // Find all methods annotated with interceptor annotations and register them as synthetic interceptors + for (Method method : findMethods(testClass, + List.of(AroundInvoke.class, PostConstruct.class, PreDestroy.class, AroundConstruct.class))) { + 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())) { + // ExtentionContext.getTestInstance() does not work + Object testInstance = context.getRoot().getStore(NAMESPACE).get(KEY_TEST_INSTANCE, + Object.class); + 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); + } + registrationContext.configureInterceptor(interceptionType) + .identifier(key) + .bindings(bindings.stream().map(Annotations::jandexAnnotation) + .toArray(AnnotationInstance[]::new)) + .param(InterceptorMethodCreator.CREATE_KEY, key) + .creator(InterceptorMethodCreator.class); + ; + } } }); @@ -694,6 +762,9 @@ public void accept(BytecodeTransformer transformer) { // 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(); @@ -729,6 +800,20 @@ public void accept(BytecodeTransformer transformer) { return oldTccl; } + 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) { @@ -791,6 +876,14 @@ static T cast(Object obj) { } private List injectFields(Class testClass, Object testInstance) throws Exception { + List injectedFields = new ArrayList<>(); + for (Field field : findInjectFields(testClass)) { + injectedFields.add(new FieldInjector(field, testInstance)); + } + return injectedFields; + } + + private List findInjectFields(Class testClass) { List> injectAnnotations; Class deprecatedInjectMock = loadDeprecatedInjectMock(); if (deprecatedInjectMock != null) { @@ -798,20 +891,41 @@ private List injectFields(Class testClass, Object testInstance } else { injectAnnotations = List.of(Inject.class, InjectMock.class); } - List injectedFields = new ArrayList<>(); + return findFields(testClass, injectAnnotations); + } + + private 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 : injectAnnotations) { + for (Class annotation : annotations) { if (field.isAnnotationPresent(annotation)) { - injectedFields.add(new FieldInjector(field, testInstance)); + fields.add(field); break; } } } current = current.getSuperclass(); } - return injectedFields; + return fields; + } + + private List findMethods(Class testClass, List> annotations) { + List methods = new ArrayList<>(); + Class current = testClass; + while (current.getSuperclass() != null) { + for (Method method : current.getDeclaredMethods()) { + for (Class annotation : annotations) { + if (method.isAnnotationPresent(annotation)) { + methods.add(method); + break; + } + } + } + current = current.getSuperclass(); + } + return methods; } static class FieldInjector { @@ -851,7 +965,10 @@ public FieldInjector(Field field, Object testInstance) throws Exception { handle.getBean())); } } else { - if (handle.getBean().getKind() != io.quarkus.arc.InjectableBean.Kind.SYNTHETIC) { + if (!handle.isAvailable()) { + throw new IllegalStateException(String + .format("The injected field %s expects a mocked bean; but obtained null", field)); + } else if (handle.getBean().getKind() != io.quarkus.arc.InjectableBean.Kind.SYNTHETIC) { throw new IllegalStateException(String .format("The injected field %s expects a mocked bean; but obtained: %s", field, handle.getBean())); @@ -861,7 +978,10 @@ public FieldInjector(Field field, Object testInstance) throws Exception { unsetHandles = List.of(handle); } - field.setAccessible(true); + if (!field.canAccess(testInstance)) { + field.setAccessible(true); + } + field.set(testInstance, injectedInstance); } @@ -875,7 +995,6 @@ void unset(Object testInstance) throws Exception { } } } - field.setAccessible(true); field.set(testInstance, null); } diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/InterceptorMethodsTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/InterceptorMethodsTest.java new file mode 100644 index 0000000000000..c1992b27434b0 --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/InterceptorMethodsTest.java @@ -0,0 +1,110 @@ +package io.quarkus.test.component.declarative; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.spi.Bean; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundConstruct; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.InterceptorBinding; +import jakarta.interceptor.InvocationContext; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ArcInvocationContext; +import io.quarkus.arc.NoClassInterceptors; +import io.quarkus.test.InjectMock; +import io.quarkus.test.component.QuarkusComponentTest; +import io.quarkus.test.component.beans.Charlie; + +@QuarkusComponentTest +public class InterceptorMethodsTest { + + static final List EVENTS = new CopyOnWriteArrayList<>(); + + @Inject + TheComponent theComponent; + + // Charlie is mocked even if it's not a dependency of a tested component + @InjectMock + Charlie charlie; + + @Test + public void testPing() { + EVENTS.clear(); + Mockito.when(charlie.ping()).thenReturn("ok"); + assertEquals("ok", theComponent.ping()); + Arc.container().getActiveContext(ApplicationScoped.class).destroy(theComponent.getBean()); + assertEquals("ac", EVENTS.get(0)); + assertEquals("pc", EVENTS.get(1)); + assertEquals("ai", EVENTS.get(2)); + assertEquals("pd", EVENTS.get(3)); + } + + @SimpleBinding + @ApplicationScoped + static class TheComponent { + + @Inject + Bean bean; + + String ping() { + return "true"; + } + + @NoClassInterceptors + public Bean getBean() { + return bean; + } + + } + + @Target({ TYPE, METHOD }) + @Retention(RUNTIME) + @InterceptorBinding + public @interface SimpleBinding { + + } + + @SimpleBinding + @AroundInvoke + Object aroundInvoke(InvocationContext context) throws Exception { + EVENTS.add("ai"); + return Boolean.parseBoolean(context.proceed().toString()) ? charlie.ping() : "false"; + } + + @SimpleBinding + @PostConstruct + void postConstruct(ArcInvocationContext context) throws Exception { + EVENTS.add("pc"); + context.proceed(); + } + + @SimpleBinding + @PreDestroy + void preDestroy(ArcInvocationContext context) throws Exception { + EVENTS.add("pd"); + context.proceed(); + } + + @SimpleBinding + @AroundConstruct + void aroundConstruct(ArcInvocationContext context) throws Exception { + EVENTS.add("ac"); + context.proceed(); + } + +} From d759d21c67f904306e5cc7b1bc6bcf56be9af04d Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Thu, 22 Jun 2023 11:43:30 +0200 Subject: [PATCH 2/3] QuarkusComponentTest: support priority declared on an interceptor method --- .../component/InterceptorMethodCreator.java | 2 +- .../test/component/MockBeanCreator.java | 2 +- .../QuarkusComponentTestExtension.java | 111 ++++++++++-------- .../declarative/InterceptorMethodsTest.java | 22 +++- 4 files changed, 80 insertions(+), 57 deletions(-) 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 9f0f09b91035c..b82442f1a6e56 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 @@ -17,7 +17,7 @@ public class InterceptorMethodCreator implements InterceptorCreator { public InterceptFunction create(SyntheticCreationalContext context) { Object createKey = context.getParams().get(CREATE_KEY); if (createKey != null) { - Function, InterceptFunction> createFun = createFunctions.get(createKey.toString()); + Function, InterceptFunction> createFun = createFunctions.get(createKey); if (createFun != null) { return createFun.apply(context); } 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 45fcc444509d9..4b888a3d74945 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 @@ -22,7 +22,7 @@ public class MockBeanCreator implements BeanCreator { public Object create(SyntheticCreationalContext context) { Object createKey = context.getParams().get(CREATE_KEY); if (createKey != null) { - Function, ?> createFun = createFunctions.get(createKey.toString()); + Function, ?> createFun = createFunctions.get(createKey); if (createFun != null) { return createFun.apply(context); } else { 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 8734595023487..c4e1c642ac853 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 @@ -34,6 +34,7 @@ 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.Instance; @@ -453,8 +454,8 @@ private static Set getQualifiers(Field field, Collection> componentClasses) { - Class testClass = context.getRequiredTestClass(); + private ClassLoader initArcContainer(ExtensionContext extensionContext, Collection> componentClasses) { + Class testClass = extensionContext.getRequiredTestClass(); // Collect all test class injection points to define a bean removal exclusion List testClassInjectionPoints = findInjectFields(testClass); @@ -584,7 +585,7 @@ public void writeResource(Resource resource) throws IOException { }); } - context.getRoot().getStore(NAMESPACE).put(KEY_GENERATED_RESOURCES, generatedResources); + extensionContext.getRoot().getStore(NAMESPACE).put(KEY_GENERATED_RESOURCES, generatedResources); builder.addAnnotationTransformer(AnnotationsTransformer.appliedToField().whenContainsAny(qualifiers) .whenContainsNone(DotName.createSimple(Inject.class)).thenTransform(t -> t.add(Inject.class))); @@ -695,53 +696,7 @@ public void register(RegistrationContext registrationContext) { unsatisfiedInjectionPoints.size()); // Find all methods annotated with interceptor annotations and register them as synthetic interceptors - for (Method method : findMethods(testClass, - List.of(AroundInvoke.class, PostConstruct.class, PreDestroy.class, AroundConstruct.class))) { - 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())) { - // ExtentionContext.getTestInstance() does not work - Object testInstance = context.getRoot().getStore(NAMESPACE).get(KEY_TEST_INSTANCE, - Object.class); - 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); - } - registrationContext.configureInterceptor(interceptionType) - .identifier(key) - .bindings(bindings.stream().map(Annotations::jandexAnnotation) - .toArray(AnnotationInstance[]::new)) - .param(InterceptorMethodCreator.CREATE_KEY, key) - .creator(InterceptorMethodCreator.class); - ; - } + processTestInterceptorMethods(testClass, extensionContext, registrationContext, interceptorBindings); } }); @@ -800,6 +755,62 @@ public void accept(BytecodeTransformer transformer) { return oldTccl; } + private void processTestInterceptorMethods(Class testClass, ExtensionContext extensionContext, + BeanRegistrar.RegistrationContext registrationContext, Set interceptorBindings) { + for (Method method : findMethods(testClass, + List.of(AroundInvoke.class, PostConstruct.class, PreDestroy.class, AroundConstruct.class))) { + 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())) { + // ExtentionContext.getTestInstance() does not work + Object testInstance = extensionContext.getRoot().getStore(NAMESPACE).get(KEY_TEST_INSTANCE, + Object.class); + 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())) { diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/InterceptorMethodsTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/InterceptorMethodsTest.java index c1992b27434b0..c74fc86e3cba6 100644 --- a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/InterceptorMethodsTest.java +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/InterceptorMethodsTest.java @@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.spi.Bean; import jakarta.inject.Inject; @@ -46,12 +47,14 @@ public class InterceptorMethodsTest { public void testPing() { EVENTS.clear(); Mockito.when(charlie.ping()).thenReturn("ok"); - assertEquals("ok", theComponent.ping()); + assertEquals("OK", theComponent.ping()); Arc.container().getActiveContext(ApplicationScoped.class).destroy(theComponent.getBean()); + assertEquals(5, EVENTS.size()); assertEquals("ac", EVENTS.get(0)); assertEquals("pc", EVENTS.get(1)); - assertEquals("ai", EVENTS.get(2)); - assertEquals("pd", EVENTS.get(3)); + assertEquals("ai2", EVENTS.get(2)); + assertEquals("ai1", EVENTS.get(3)); + assertEquals("pd", EVENTS.get(4)); } @SimpleBinding @@ -79,13 +82,22 @@ public Bean getBean() { } + @Priority(20) @SimpleBinding @AroundInvoke - Object aroundInvoke(InvocationContext context) throws Exception { - EVENTS.add("ai"); + Object aroundInvoke1(InvocationContext context) throws Exception { + EVENTS.add("ai1"); return Boolean.parseBoolean(context.proceed().toString()) ? charlie.ping() : "false"; } + // default priority is 1 + @SimpleBinding + @AroundInvoke + Object aroundInvoke2(InvocationContext context) throws Exception { + EVENTS.add("ai2"); + return context.proceed().toString().toUpperCase(); + } + @SimpleBinding @PostConstruct void postConstruct(ArcInvocationContext context) throws Exception { From 6245f0039a4cc142902298320195f0f3fd4c9f74 Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Thu, 22 Jun 2023 14:45:12 +0200 Subject: [PATCH 3/3] QuarkusComponentTest: docs for mocking interceptors --- .../asciidoc/getting-started-testing.adoc | 102 +++++++++++++++++- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/docs/src/main/asciidoc/getting-started-testing.adoc b/docs/src/main/asciidoc/getting-started-testing.adoc index 5332aaa6093ba..d9637db67618e 100644 --- a/docs/src/main/asciidoc/getting-started-testing.adoc +++ b/docs/src/main/asciidoc/getting-started-testing.adoc @@ -1573,8 +1573,8 @@ public class FooTest { } ---- <1> The `QuarkusComponentTest` annotation registers the JUnit extension. -<2> Set a configuration property for the test. -<3> The test injects the component under the test. The types of all fields annotated with `@Inject` are considered the component types under test. You can also specify additional component classes via `@QuarkusComponentTest#value()`. +<2> Sets a configuration property for the test. +<3> The test injects the component under the test. The types of all fields annotated with `@Inject` are considered the component types under test. You can also specify additional component classes via `@QuarkusComponentTest#value()`. Furthermore, the static nested classes declared on the test class are components too. <4> The test also injects `Charlie`, a dependency for which a synthetic `@Singleton` bean is registered automatically. The injected reference is an "unconfigured" Mockito mock. <5> We can leverage the Mockito API in a test method to configure the behavior. @@ -1609,7 +1609,7 @@ public class FooTest { } } ---- -<1> The `QuarkusComponentTestExtension` is configured in a static field. +<1> The `QuarkusComponentTestExtension` is configured in a static field of the test class. === Lifecycle @@ -1638,4 +1638,98 @@ You can use the mock configurator API via the `QuarkusComponentTestExtension#moc A dedicated `SmallRyeConfig` is registered during the `before all` test phase. Moreover, it's possible to set the configuration properties via the `QuarkusComponentTestExtension#configProperty(String, String)` method or the `@TestConfigProperty` annotation. -If you only need to use the default values for missing config properties, then the `QuarkusComponentTestExtension#useDefaultConfigProperties()` or `@QuarkusComponentTest#useDefaultConfigProperties()` might come in useful. \ No newline at end of file +If you only need to use the default values for missing config properties, then the `QuarkusComponentTestExtension#useDefaultConfigProperties()` or `@QuarkusComponentTest#useDefaultConfigProperties()` might come in useful. + +=== Mocking CDI Interceptors + +If a tested component class declares an interceptor binding then you might need to mock the interception too. +There are two ways to accomplish this task. +First, you can define an interceptor class as a static nested class of the test class. + +[source, java] +---- +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; +import io.quarkus.test.component.QuarkusComponentTest; +import org.junit.jupiter.api.Test; + +@QuarkusComponentTest +public class FooTest { + + @Inject + Foo foo; + + @Test + public void testPing() { + assertEquals("OK", foo.ping()); + } + + @ApplicationScoped + static class Foo { + + @SimpleBinding <1> + String ping() { + return "ok"; + } + + } + + @SimpleBinding + @Interceptor + static class SimpleInterceptor { <2> + + @AroundInvoke + Object aroundInvoke(InvocationContext context) throws Exception { + return context.proceed().toString().toUpperCase(); + } + + } +} +---- +<1> `@SimpleBinding` is an interceptor binding. +<2> The interceptor class is automatically considered a tested component. + +NOTE: Static nested classed declared on a test class that is annotated with `@QuarkusComponentTest` are excluded from bean discovery when running a `@QuarkusTest` in order to prevent unintentional CDI conflicts. + +Furthermore, you can also declare a "test interceptor method" directly on the test class. +This method is then invoked in the relevant interception phase. + +[source, java] +---- +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; +import io.quarkus.test.component.QuarkusComponentTest; +import org.junit.jupiter.api.Test; + +@QuarkusComponentTest +public class FooTest { + + @Inject + Foo foo; + + @Test + public void testPing() { + assertEquals("OK", foo.ping()); + } + + @SimpleBinding <1> + @AroundInvoke <2> + Object aroundInvoke(InvocationContext context) throws Exception { + return context.proceed().toString().toUpperCase(); + } + + @ApplicationScoped + static class Foo { + + @SimpleBinding <1> + String ping() { + return "ok"; + } + + } +} +---- +<1> The interceptor bindings of the resulting interceptor are specified by annotating the method with the interceptor binding types. +<2> Defines the interception type.