diff --git a/docs/src/main/asciidoc/getting-started-testing.adoc b/docs/src/main/asciidoc/getting-started-testing.adoc index e2c0fac89ccbe..35c28cc97071a 100644 --- a/docs/src/main/asciidoc/getting-started-testing.adoc +++ b/docs/src/main/asciidoc/getting-started-testing.adoc @@ -369,6 +369,8 @@ public class GreetingServiceTest { ---- <1> The `GreetingService` bean will be injected into the test +TIP: If you want to inject/test a `@SessionScoped` bean then it's very likely that the session context is not active and you would receive the `ContextNotActiveException` when a method of the injected bean is invoked. However, it's possible to use the `@io.quarkus.test.ActivateSessionContext` interceptor binding to activate the session context for a specific business method. Please read the javadoc for futher limitations. + == Applying Interceptors to Tests As mentioned above Quarkus tests are actually full CDI beans, and as such you can apply CDI interceptors as you would diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java index ff175046a6960..a89b988cb9184 100644 --- a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcProcessor.java @@ -68,16 +68,13 @@ import io.quarkus.arc.runtime.LoggerProducer; import io.quarkus.arc.runtime.appcds.AppCDSRecorder; import io.quarkus.arc.runtime.context.ArcContextProvider; -import io.quarkus.arc.runtime.test.PreloadedTestApplicationClassPredicate; import io.quarkus.bootstrap.BootstrapDebug; import io.quarkus.deployment.Capabilities; import io.quarkus.deployment.Capability; import io.quarkus.deployment.Feature; -import io.quarkus.deployment.IsTest; import io.quarkus.deployment.annotations.BuildProducer; import io.quarkus.deployment.annotations.BuildStep; import io.quarkus.deployment.annotations.Consume; -import io.quarkus.deployment.annotations.ExecutionTime; import io.quarkus.deployment.annotations.Produce; import io.quarkus.deployment.annotations.Record; import io.quarkus.deployment.builditem.AdditionalApplicationArchiveMarkerBuildItem; @@ -653,27 +650,6 @@ public void signalBeanContainerReady(AppCDSRecorder recorder, PreBeanContainerBu beanContainerProducer.produce(new BeanContainerBuildItem(bi.getValue())); } - @BuildStep(onlyIf = IsTest.class) - public AdditionalBeanBuildItem testApplicationClassPredicateBean() { - // We need to register the bean implementation for TestApplicationClassPredicate - // TestApplicationClassPredicate is used programmatically in the ArC recorder when StartupEvent is fired - return AdditionalBeanBuildItem.unremovableOf(PreloadedTestApplicationClassPredicate.class); - } - - @BuildStep(onlyIf = IsTest.class) - @Record(ExecutionTime.STATIC_INIT) - void initTestApplicationClassPredicateBean(ArcRecorder recorder, BeanContainerBuildItem beanContainer, - BeanDiscoveryFinishedBuildItem beanDiscoveryFinished, - CompletedApplicationClassPredicateBuildItem predicate) { - Set applicationBeanClasses = new HashSet<>(); - for (BeanInfo bean : beanDiscoveryFinished.beanStream().classBeans()) { - if (predicate.test(bean.getBeanClass())) { - applicationBeanClasses.add(bean.getBeanClass().toString()); - } - } - recorder.initTestApplicationClassPredicate(applicationBeanClasses); - } - @BuildStep List marker() { return Arrays.asList(new AdditionalApplicationArchiveMarkerBuildItem("META-INF/beans.xml"), diff --git a/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcTestSteps.java b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcTestSteps.java new file mode 100644 index 0000000000000..66c86e0b055e5 --- /dev/null +++ b/extensions/arc/deployment/src/main/java/io/quarkus/arc/deployment/ArcTestSteps.java @@ -0,0 +1,71 @@ +package io.quarkus.arc.deployment; + +import java.util.HashSet; +import java.util.Set; +import java.util.function.Predicate; + +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTransformation; +import org.jboss.jandex.DotName; + +import io.quarkus.arc.processor.BeanInfo; +import io.quarkus.arc.runtime.ArcRecorder; +import io.quarkus.arc.runtime.test.ActivateSessionContextInterceptor; +import io.quarkus.arc.runtime.test.PreloadedTestApplicationClassPredicate; +import io.quarkus.deployment.IsTest; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.BuildSteps; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.ApplicationClassPredicateBuildItem; + +@BuildSteps(onlyIf = IsTest.class) +public class ArcTestSteps { + + @BuildStep + public void additionalBeans(BuildProducer additionalBeans) { + // We need to register the bean implementation for TestApplicationClassPredicate + // TestApplicationClassPredicate is used programmatically in the ArC recorder when StartupEvent is fired + additionalBeans.produce(AdditionalBeanBuildItem.unremovableOf(PreloadedTestApplicationClassPredicate.class)); + // In tests, register the ActivateSessionContextInterceptor and ActivateSessionContext interceptor binding + additionalBeans.produce(new AdditionalBeanBuildItem(ActivateSessionContextInterceptor.class)); + additionalBeans.produce(new AdditionalBeanBuildItem("io.quarkus.test.ActivateSessionContext")); + } + + @BuildStep + AnnotationsTransformerBuildItem addInterceptorBinding() { + return new AnnotationsTransformerBuildItem( + AnnotationTransformation.forClasses().whenClass(ActivateSessionContextInterceptor.class).transform(tc -> tc.add( + AnnotationInstance.builder(DotName.createSimple("io.quarkus.test.ActivateSessionContext")).build()))); + } + + // For some reason the annotation literal generated for io.quarkus.test.ActivateSessionContext lives in app class loader. + // This predicates ensures that the generated bean is considered an app class too. + // As a consequence, the type and all methods of ActivateSessionContextInterceptor must be public. + @BuildStep + ApplicationClassPredicateBuildItem appClassPredicate() { + return new ApplicationClassPredicateBuildItem(new Predicate() { + + @Override + public boolean test(String name) { + return name.startsWith(ActivateSessionContextInterceptor.class.getName()); + } + }); + } + + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void initTestApplicationClassPredicateBean(ArcRecorder recorder, BeanContainerBuildItem beanContainer, + BeanDiscoveryFinishedBuildItem beanDiscoveryFinished, + CompletedApplicationClassPredicateBuildItem predicate) { + Set applicationBeanClasses = new HashSet<>(); + for (BeanInfo bean : beanDiscoveryFinished.beanStream().classBeans()) { + if (predicate.test(bean.getBeanClass())) { + applicationBeanClasses.add(bean.getBeanClass().toString()); + } + } + recorder.initTestApplicationClassPredicate(applicationBeanClasses); + } + +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/Client.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/Client.java new file mode 100644 index 0000000000000..358756bab8210 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/Client.java @@ -0,0 +1,32 @@ +package io.quarkus.arc.test.context.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import jakarta.enterprise.context.Dependent; +import jakarta.enterprise.context.SessionScoped; +import jakarta.inject.Inject; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ClientProxy; +import io.quarkus.test.ActivateSessionContext; + +@Dependent +class Client { + + @Inject + SimpleBean bean; + + @ActivateSessionContext + public boolean ping() { + assertTrue(Arc.container().sessionContext().isActive()); + if (bean instanceof ClientProxy proxy) { + assertEquals(SessionScoped.class, proxy.arc_bean().getScope()); + } else { + fail("Not a client proxy"); + } + return bean.ping(); + } + +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SessionContextTest.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SessionContextTest.java new file mode 100644 index 0000000000000..74ed4d9475fc3 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SessionContextTest.java @@ -0,0 +1,31 @@ +package io.quarkus.arc.test.context.session; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import jakarta.inject.Inject; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import io.quarkus.arc.Arc; +import io.quarkus.test.QuarkusUnitTest; + +public class SessionContextTest { + + @RegisterExtension + static final QuarkusUnitTest config = new QuarkusUnitTest() + .withApplicationRoot(root -> root + .addClasses(SimpleBean.class, Client.class)); + + @Inject + Client client; + + @Test + public void testContexts() { + assertFalse(Arc.container().sessionContext().isActive()); + assertTrue(client.ping()); + assertTrue(SimpleBean.DESTROYED.get()); + assertFalse(Arc.container().sessionContext().isActive()); + } +} diff --git a/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SimpleBean.java b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SimpleBean.java new file mode 100644 index 0000000000000..47e2c73e3ff26 --- /dev/null +++ b/extensions/arc/deployment/src/test/java/io/quarkus/arc/test/context/session/SimpleBean.java @@ -0,0 +1,21 @@ +package io.quarkus.arc.test.context.session; + +import java.util.concurrent.atomic.AtomicBoolean; + +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.SessionScoped; + +@SessionScoped +class SimpleBean { + + static final AtomicBoolean DESTROYED = new AtomicBoolean(); + + public boolean ping() { + return true; + } + + @PreDestroy + void destroy() { + DESTROYED.set(true); + } +} \ No newline at end of file diff --git a/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/test/ActivateSessionContextInterceptor.java b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/test/ActivateSessionContextInterceptor.java new file mode 100644 index 0000000000000..ea5452e2babc7 --- /dev/null +++ b/extensions/arc/runtime/src/main/java/io/quarkus/arc/runtime/test/ActivateSessionContextInterceptor.java @@ -0,0 +1,30 @@ +package io.quarkus.arc.runtime.test; + +import jakarta.annotation.Priority; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; + +import io.quarkus.arc.Arc; +import io.quarkus.arc.ManagedContext; + +// The @ActivateSessionContext interceptor binding is added by the extension +@Interceptor +@Priority(Interceptor.Priority.PLATFORM_BEFORE + 100) +public class ActivateSessionContextInterceptor { + + @AroundInvoke + public Object aroundInvoke(InvocationContext ctx) throws Exception { + ManagedContext sessionContext = Arc.container().sessionContext(); + if (sessionContext.isActive()) { + return ctx.proceed(); + } + try { + sessionContext.activate(); + return ctx.proceed(); + } finally { + sessionContext.terminate(); + } + } + +} diff --git a/test-framework/common/src/main/java/io/quarkus/test/ActivateSessionContext.java b/test-framework/common/src/main/java/io/quarkus/test/ActivateSessionContext.java new file mode 100644 index 0000000000000..0768213c6b1d5 --- /dev/null +++ b/test-framework/common/src/main/java/io/quarkus/test/ActivateSessionContext.java @@ -0,0 +1,30 @@ +package io.quarkus.test; + +import static java.lang.annotation.ElementType.METHOD; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; +import java.util.concurrent.CompletionStage; + +import jakarta.interceptor.InterceptorBinding; + +/** + * Activates the session context before the intercepted method is called, and terminates the context when the method invocation + * completes (regardless of any exceptions being thrown). + *

+ * If the context is already active, it's a noop - the context is neither activated nor deactivated. + *

+ * Keep in mind that if the method returns an asynchronous type (such as {@link CompletionStage} then the session context is + * still terminate when the invocation completes and not at the time the asynchronous type is completed. Also note that session + * context is not propagated by MicroProfile Context Propagation. + *

+ * This interceptor binding is only available in tests. + */ +@InterceptorBinding +@Target({ METHOD, TYPE }) +@Retention(RUNTIME) +public @interface ActivateSessionContext { + +}