From 8507b22addf0f194fba5983d33cd9c6594c2cf7e Mon Sep 17 00:00:00 2001 From: Martin Kouba Date: Wed, 24 Jan 2024 21:28:51 +0100 Subject: [PATCH] QuarkusComponentTest: support test method parameter injection - params annotated with SkipInject are never injected --- .../asciidoc/getting-started-testing.adoc | 39 ++- .../main/java/io/quarkus/test/InjectMock.java | 9 +- .../test/component/QuarkusComponentTest.java | 3 +- .../QuarkusComponentTestConfiguration.java | 35 ++- .../QuarkusComponentTestExtension.java | 230 +++++++++++++++--- .../io/quarkus/test/component/SkipInject.java | 16 ++ .../test/component/beans/MyComponent.java | 4 + .../declarative/ListAllMockTest.java | 5 + .../ParameterInjectionDependentTest.java | 45 ++++ ...rameterInjectionPerClassLifecycleTest.java | 46 ++++ ...ameterInjectionPerMethodLifecycleTest.java | 47 ++++ .../paraminject/ParameterInjectionTest.java | 84 +++++++ 12 files changed, 512 insertions(+), 51 deletions(-) create mode 100644 test-framework/junit5-component/src/main/java/io/quarkus/test/component/SkipInject.java create mode 100644 test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionDependentTest.java create mode 100644 test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerClassLifecycleTest.java create mode 100644 test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerMethodLifecycleTest.java create mode 100644 test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionTest.java diff --git a/docs/src/main/asciidoc/getting-started-testing.adoc b/docs/src/main/asciidoc/getting-started-testing.adoc index e24923671bd91d..27b6954d83d5b4 100644 --- a/docs/src/main/asciidoc/getting-started-testing.adoc +++ b/docs/src/main/asciidoc/getting-started-testing.adoc @@ -1596,8 +1596,35 @@ public class FooTest { <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. -If you need the full control over the `QuarkusComponentTestExtension` configuration then you can use the `@RegisterExtension` annotation and configure the extension programatically. -The test above could be rewritten like: +`QuarkusComponentTestExtension` also resolves arguments for parameters of a test method and injects the matching beans. +So the code snippet above can be rewritten as: + +[source, java] +---- +import static org.junit.jupiter.api.Assertions.assertEquals; + +import jakarta.inject.Inject; +import io.quarkus.test.InjectMock; +import io.quarkus.test.component.TestConfigProperty; +import io.quarkus.test.component.QuarkusComponentTest; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +@QuarkusComponentTest +@TestConfigProperty(key = "bar", value = "true") +public class FooTest { + + @Test + public void testPing(Foo foo, @InjectMock Charlie charlieMock) { <1> + Mockito.when(charlieMock.ping()).thenReturn("OK"); + assertEquals("OK", foo.ping()); + } +} +---- +<1> Parameters annotated with `@io.quarkus.test.component.SkipInject` are never resolved by this extension. + +Furthermore, if you need the full control over the `QuarkusComponentTestExtension` configuration then you can use the `@RegisterExtension` annotation and configure the extension programatically. +The original test could be rewritten like: [source, java] ---- @@ -1638,13 +1665,19 @@ However, if the test instance lifecycle is `Lifecycle#PER_CLASS` then the conta The fields annotated with `@Inject` and `@InjectMock` are injected after a test instance is created. Finally, the CDI request context is activated and terminated per each test method. +=== Injection +Test class fields annotated with `@jakarta.inject.Inject` and `@io.quarkus.test.InjectMock` are injected after a test instance is created. +Dependent beans injected into these fields are correctly destroyed before a test instance is destroyed. +Parameters of a test method for which a matching bean exists are resolved unless annotated with `@io.quarkus.test.component.SkipInject`. +Dependent beans injected into the test method arguments are correctly destroyed after the test method completes. + === Auto Mocking Unsatisfied Dependencies Unlike in regular CDI environments the test does not fail if a component injects an unsatisfied dependency. Instead, a synthetic bean is registered automatically for each combination of required type and qualifiers of an injection point that resolves to an unsatisfied dependency. The bean has the `@Singleton` scope so it's shared across all injection points with the same required type and qualifiers. The injected reference is an _unconfigured_ Mockito mock. -You can inject the mock in your test and leverage the Mockito API to configure the behavior. +You can inject the mock in your test using the `io.quarkus.test.InjectMock` annotation and leverage the Mockito API to configure the behavior. === Custom Mocks For Unsatisfied Dependencies diff --git a/test-framework/common/src/main/java/io/quarkus/test/InjectMock.java b/test-framework/common/src/main/java/io/quarkus/test/InjectMock.java index af4292f1f853a6..c8daa498f98631 100644 --- a/test-framework/common/src/main/java/io/quarkus/test/InjectMock.java +++ b/test-framework/common/src/main/java/io/quarkus/test/InjectMock.java @@ -1,18 +1,19 @@ package io.quarkus.test; import static java.lang.annotation.ElementType.FIELD; +import static java.lang.annotation.ElementType.PARAMETER; import static java.lang.annotation.RetentionPolicy.RUNTIME; import java.lang.annotation.Retention; import java.lang.annotation.Target; /** - * Instructs the test engine to inject a mock instance of a bean in the field of a test class. + * Instructs the test engine to inject a mock instance of a bean in the target annotated element in a test class. *

* This annotation is supported: *

* The lifecycle and configuration API of the injected mock depends on the type of test being used. *

@@ -21,7 +22,7 @@ * {@code io.quarkus.test.junit.QuarkusTest}. */ @Retention(RUNTIME) -@Target(FIELD) +@Target({ FIELD, PARAMETER }) public @interface InjectMock { } diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTest.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTest.java index 8b5f82d9d5ddbc..5a354087ea06aa 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTest.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTest.java @@ -28,7 +28,8 @@ * The set of additional components under test. *

* The initial set of components is derived from the test class. The types of all fields annotated with - * {@link jakarta.inject.Inject} are considered the component types. + * {@link jakarta.inject.Inject} are considered the component types. Furthermore, all types of parameters of test methods + * that are not annotated with {@link InjectMock} or {@link SkipInject} are also considered the component types. * * @return the components under test */ diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestConfiguration.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestConfiguration.java index 8d6d624903b2ba..dee50418ce2d9d 100644 --- a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestConfiguration.java +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/QuarkusComponentTestConfiguration.java @@ -3,6 +3,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -17,9 +18,12 @@ import jakarta.inject.Provider; import org.jboss.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; import io.quarkus.arc.InjectableInstance; import io.quarkus.arc.processor.AnnotationsTransformer; +import io.quarkus.test.InjectMock; class QuarkusComponentTestConfiguration { @@ -74,23 +78,36 @@ QuarkusComponentTestConfiguration update(Class testClass) { } } } - // All fields annotated with @Inject represent component classes Class current = testClass; - while (current != null) { + while (current != null && current != Object.class) { + // All fields annotated with @Inject represent component classes for (Field field : current.getDeclaredFields()) { if (field.isAnnotationPresent(Inject.class) && !resolvesToBuiltinBean(field.getType())) { componentClasses.add(field.getType()); } } - current = current.getSuperclass(); - } - // All static nested classes declared on the test class are components - if (addNestedClassesAsComponents) { - for (Class declaredClass : testClass.getDeclaredClasses()) { - if (Modifier.isStatic(declaredClass.getModifiers())) { - componentClasses.add(declaredClass); + // All static nested classes declared on the test class are components + if (addNestedClassesAsComponents) { + for (Class declaredClass : current.getDeclaredClasses()) { + if (Modifier.isStatic(declaredClass.getModifiers())) { + componentClasses.add(declaredClass); + } } } + // All params of test methods but not TestInfo, not annotated with @InjectMock and not annotated with @SkipInject + for (Method method : current.getDeclaredMethods()) { + if (method.isAnnotationPresent(Test.class)) { + for (Parameter param : method.getParameters()) { + if (param.getType() == TestInfo.class + || param.isAnnotationPresent(InjectMock.class) + || param.isAnnotationPresent(SkipInject.class)) { + continue; + } + componentClasses.add(param.getType()); + } + } + } + current = current.getSuperclass(); } List testConfigProperties = new ArrayList<>(); 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 15a2f6e2d7fc02..a3e56d0cee25b5 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 @@ -25,17 +25,20 @@ 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.inject.Default; import jakarta.enterprise.inject.spi.BeanManager; import jakarta.enterprise.inject.spi.InjectionPoint; import jakarta.enterprise.inject.spi.InterceptionType; @@ -58,12 +61,17 @@ import org.jboss.jandex.Type; import org.jboss.jandex.Type.Kind; import org.jboss.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; import org.junit.jupiter.api.TestInstance.Lifecycle; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.TestInstancePostProcessor; @@ -108,14 +116,23 @@ * programmatically with a static field of type {@link QuarkusComponentTestExtension}, annotated with {@link RegisterExtension} * and initialized with {@link #QuarkusComponentTestExtension(Class...) simplified constructor} or using the {@link #builder() * builder}. + * + *

Container lifecycle

*

* This extension starts the CDI container and registers a dedicated SmallRyeConfig. If {@link Lifecycle#PER_METHOD} is used * (default) then the container is started during the {@code before each} test phase and stopped during the {@code after each} * test phase. However, if {@link Lifecycle#PER_CLASS} is used then the container is started during the {@code before all} test - * phase and stopped during the {@code after all} test phase. The fields annotated with {@code jakarta.inject.Inject} are - * injected after a test instance is created and unset before a test instance is destroyed. Moreover, the dependent beans - * injected into fields annotated with {@code jakarta.inject.Inject} are correctly destroyed before a test instance is - * destroyed. Finally, the CDI request context is activated and terminated per each test method. + * phase and stopped during the {@code after all} test phase. The CDI request context is activated and terminated per each test + * method. + * + *

Injection

+ *

+ * Test class fields annotated with {@link jakarta.inject.Inject} and {@link io.quarkus.test.InjectMock} are injected after a + * test instance is created and unset before a test instance is destroyed. Dependent beans injected into these + * fields are correctly destroyed before a test instance is destroyed. + *

+ * Parameters of a test method for which a matching bean exists are resolved unless annotated with {@link SkipInject}. Dependent + * beans injected into the test method arguments are correctly destroyed after the test method completes. * *

Auto Mocking Unsatisfied Dependencies

*

@@ -123,7 +140,7 @@ * synthetic bean is registered automatically for each combination of required type and qualifiers of an injection point that * resolves to an unsatisfied dependency. The bean has the {@link Singleton} scope so it's shared across all injection points * with the same required type and qualifiers. The injected reference is an unconfigured Mockito mock. You can inject the mock - * in your test and leverage the Mockito API to configure the behavior. + * in your test using the {@link io.quarkus.test.InjectMock} annotation and leverage the Mockito API to configure the behavior. * *

Custom Mocks For Unsatisfied Dependencies

*

@@ -135,7 +152,8 @@ */ @Experimental("This feature is experimental and the API may change in the future") public class QuarkusComponentTestExtension - implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback, TestInstancePostProcessor { + implements BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback, TestInstancePostProcessor, + ParameterResolver { public static QuarkusComponentTestExtensionBuilder builder() { return new QuarkusComponentTestExtensionBuilder(); @@ -151,6 +169,7 @@ public static QuarkusComponentTestExtensionBuilder builder() { 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_INJECTED_PARAMS = "injectedParams"; private static final String KEY_TEST_INSTANCE = "testInstance"; private static final String KEY_CONFIG = "config"; private static final String KEY_TEST_CLASS_CONFIG = "testClassConfig"; @@ -192,6 +211,7 @@ public void beforeAll(ExtensionContext context) throws Exception { @Override public void afterAll(ExtensionContext context) throws Exception { long start = System.nanoTime(); + // Stop the container if Lifecycle.PER_CLASS is used stopContainer(context, Lifecycle.PER_CLASS); cleanup(context); LOG.debugf("afterAll: %s ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); @@ -211,6 +231,9 @@ public void afterEach(ExtensionContext context) throws Exception { long start = System.nanoTime(); // Terminate the request context Arc.container().requestContext().terminate(); + // Destroy @Dependent beans injected as test method parameters correctly + destroyDependentTestMethodParams(context); + // Stop the container if Lifecycle.PER_METHOD is used stopContainer(context, Lifecycle.PER_METHOD); LOG.debugf("afterEach: %s ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); } @@ -222,6 +245,68 @@ public void postProcessTestInstance(Object testInstance, ExtensionContext contex LOG.debugf("postProcessTestInstance: %s ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)); } + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + // 1. Only test methods are supported + // 2. A param annotated with @SkipInject is never supported + if (parameterContext.getTarget().isPresent() + && !parameterContext.isAnnotated(SkipInject.class) + && parameterContext.getDeclaringExecutable().isAnnotationPresent(Test.class)) { + java.lang.reflect.Type requiredType = parameterContext.getParameter().getParameterizedType(); + if (!requiredType.equals(TestInfo.class)) { + BeanManager beanManager = Arc.container().beanManager(); + Annotation[] qualifiers = getQualifiers(parameterContext.getAnnotatedElement(), beanManager); + if (qualifiers.length > 0 && Arrays.stream(qualifiers).anyMatch(All.Literal.INSTANCE::equals)) { + // @All List<> + if (isListRequiredType(requiredType)) { + return true; + } else { + throw new IllegalStateException("Invalid injection point type: " + parameterContext.getParameter()); + } + } else { + // Only resolve the parameter if a single matching bean exists + return beanManager.resolve(beanManager.getBeans(requiredType, qualifiers)) != null; + } + } + } + return false; + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext context) + throws ParameterResolutionException { + @SuppressWarnings("unchecked") + List> injectedParams = context.getRoot().getStore(NAMESPACE).get(KEY_INJECTED_PARAMS, List.class); + ArcContainer container = Arc.container(); + BeanManager beanManager = container.beanManager(); + java.lang.reflect.Type requiredType = parameterContext.getParameter().getParameterizedType(); + Annotation[] qualifiers = getQualifiers(parameterContext.getAnnotatedElement(), beanManager); + if (qualifiers.length > 0 && Arrays.stream(qualifiers).anyMatch(All.Literal.INSTANCE::equals)) { + // Special handling for @Injec @All List<> + return handleListAll(requiredType, qualifiers, container, injectedParams); + } else { + InstanceHandle handle = container.instance(requiredType, qualifiers); + injectedParams.add(handle); + return handle.get(); + } + } + + private void destroyDependentTestMethodParams(ExtensionContext context) { + @SuppressWarnings("unchecked") + List> injectedParams = context.getRoot().getStore(NAMESPACE).get(KEY_INJECTED_PARAMS, List.class); + for (InstanceHandle handle : injectedParams) { + if (handle.getBean() != null && handle.getBean().getScope().equals(Dependent.class)) { + try { + handle.destroy(); + } catch (Exception e) { + LOG.errorf(e, "Unable to destroy the injected %s", handle.getBean()); + } + } + } + injectedParams.clear(); + } + private void buildContainer(ExtensionContext context) { QuarkusComponentTestConfiguration testClassConfiguration = baseConfiguration .update(context.getRequiredTestClass()); @@ -316,6 +401,8 @@ private void startContainer(ExtensionContext context, Lifecycle testInstanceLife Object testInstance = context.getRequiredTestInstance(); context.getRoot().getStore(NAMESPACE).put(KEY_INJECTED_FIELDS, injectFields(context.getRequiredTestClass(), testInstance)); + // Injected test method parameters + context.getRoot().getStore(NAMESPACE).put(KEY_INJECTED_PARAMS, new CopyOnWriteArrayList<>()); } } @@ -345,9 +432,9 @@ public void register(RegistrationContext context) { }; } - private static Annotation[] getQualifiers(Field field, BeanManager beanManager) { + private static Annotation[] getQualifiers(AnnotatedElement element, BeanManager beanManager) { List ret = new ArrayList<>(); - Annotation[] annotations = field.getDeclaredAnnotations(); + Annotation[] annotations = element.getDeclaredAnnotations(); for (Annotation fieldAnnotation : annotations) { if (beanManager.isQualifier(fieldAnnotation.annotationType())) { ret.add(fieldAnnotation); @@ -356,10 +443,10 @@ private static Annotation[] getQualifiers(Field field, BeanManager beanManager) return ret.toArray(new Annotation[0]); } - private static Set getQualifiers(Field field, Collection qualifiers) { + private static Set getQualifiers(AnnotatedElement element, Collection qualifiers) { Set ret = new HashSet<>(); - Annotation[] fieldAnnotations = field.getDeclaredAnnotations(); - for (Annotation annotation : fieldAnnotations) { + Annotation[] annotations = element.getDeclaredAnnotations(); + for (Annotation annotation : annotations) { if (qualifiers.contains(DotName.createSimple(annotation.annotationType()))) { ret.add(Annotations.jandexAnnotation(annotation)); } @@ -369,8 +456,9 @@ private static Set getQualifiers(Field field, Collection testClass = extensionContext.getRequiredTestClass(); - // Collect all test class injection points to define a bean removal exclusion - List testClassInjectionPoints = findInjectFields(testClass); + // Collect all component injection points to define a bean removal exclusion + List injectFields = findInjectFields(testClass); + List injectParams = findInjectParams(testClass); if (configuration.componentClasses.isEmpty()) { throw new IllegalStateException("No component classes to test"); @@ -416,18 +504,24 @@ private ClassLoader initArcContainer(ExtensionContext extensionContext, QuarkusC .setName(testClass.getName().replace('.', '_')) .addRemovalExclusion(b -> { // Do not remove beans: - // 1. Injected in the test class + // 1. Injected in the test class or in a test method parameter // 2. Annotated with @Unremovable if (b.getTarget().isPresent() && b.getTarget().get().hasDeclaredAnnotation(Unremovable.class)) { return true; } - for (Field injectionPoint : testClassInjectionPoints) { + for (Field injectionPoint : injectFields) { if (beanResolver.get().matches(b, Types.jandexType(injectionPoint.getGenericType()), getQualifiers(injectionPoint, qualifiers))) { return true; } } + for (Parameter param : injectParams) { + if (beanResolver.get().matches(b, Types.jandexType(param.getParameterizedType()), + getQualifiers(param, qualifiers))) { + return true; + } + } return false; }) .setImmutableBeanArchiveIndex(index) @@ -522,9 +616,10 @@ public void register(RegistrationContext registrationContext) { DotName configPropertyDotName = DotName.createSimple(ConfigProperty.class); DotName configMappingDotName = DotName.createSimple(ConfigMapping.class); - // Analyze injection points - // - find Config, @ConfigProperty and config mappings injection points - // - find unsatisfied injection points + // 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()) { @@ -576,7 +671,7 @@ public void register(RegistrationContext registrationContext) { LOG.debugf("Unsatisfied injection point found: %s", injectionPoint.getTargetInfo()); } - // Make sure that all @InjectMock fields are also considered unsatisfied dependencies + // 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); @@ -586,6 +681,14 @@ public void register(RegistrationContext registrationContext) { 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()); @@ -704,8 +807,17 @@ public void accept(BytecodeTransformer transformer) { 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))) { + 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); @@ -849,6 +961,36 @@ private List findInjectFields(Class testClass) { return findFields(testClass, injectAnnotations); } + private List findInjectParams(Class testClass) { + List testMethods = findMethods(testClass, m -> m.isAnnotationPresent(Test.class)); + List ret = new ArrayList<>(); + for (Method method : testMethods) { + for (Parameter param : method.getParameters()) { + if (param.getType().equals(TestInfo.class) + || param.isAnnotationPresent(InjectMock.class) + || param.isAnnotationPresent(SkipInject.class)) { + continue; + } + ret.add(param); + } + } + return ret; + } + + private List findInjectMockParams(Class testClass) { + List testMethods = findMethods(testClass, m -> m.isAnnotationPresent(Test.class)); + List ret = new ArrayList<>(); + for (Method method : testMethods) { + for (Parameter param : method.getParameters()) { + if (param.isAnnotationPresent(InjectMock.class) + && !param.getType().equals(TestInfo.class)) { + ret.add(param); + } + } + } + return ret; + } + private List findFields(Class testClass, List> annotations) { List fields = new ArrayList<>(); Class current = testClass; @@ -866,16 +1008,13 @@ private List findFields(Class testClass, List findMethods(Class testClass, List> annotations) { + private List findMethods(Class testClass, Predicate methodPredicate) { 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; - } + if (methodPredicate.test(method)) { + methods.add(method); } } current = current.getSuperclass(); @@ -901,13 +1040,8 @@ public FieldInjector(Field field, Object testInstance) throws Exception { if (qualifiers.length > 0 && Arrays.stream(qualifiers).anyMatch(All.Literal.INSTANCE::equals)) { // Special handling for @Injec @All List if (isListRequiredType(requiredType)) { - List> handles = container.listAll(requiredType, qualifiers); - if (isTypeArgumentInstanceHandle(requiredType)) { - injectedInstance = handles; - } else { - injectedInstance = handles.stream().map(InstanceHandle::get).collect(Collectors.toUnmodifiableList()); - } - unsetHandles = cast(handles); + unsetHandles = new ArrayList<>(); + injectedInstance = handleListAll(requiredType, qualifiers, container, unsetHandles); } else { throw new IllegalStateException("Invalid injection point type: " + field); } @@ -955,6 +1089,23 @@ void unset(Object testInstance) throws Exception { } + private static Object handleListAll(java.lang.reflect.Type requiredType, Annotation[] qualifiers, ArcContainer container, + Collection> cleanupHandles) { + // Remove @All and add @Default if empty + Set qualifiersSet = new HashSet<>(); + Collections.addAll(qualifiersSet, qualifiers); + qualifiersSet.remove(All.Literal.INSTANCE); + if (qualifiersSet.isEmpty()) { + qualifiers = new Annotation[] { Default.Literal.INSTANCE }; + } else { + qualifiers = qualifiersSet.toArray(new Annotation[] {}); + } + List> handles = container.listAll(getListRequiredType(requiredType), qualifiers); + cleanupHandles.addAll(handles); + return isTypeArgumentInstanceHandle(requiredType) ? handles + : handles.stream().map(InstanceHandle::get).collect(Collectors.toUnmodifiableList()); + } + @SuppressWarnings("unchecked") private Class loadDeprecatedInjectMock() { try { @@ -972,6 +1123,17 @@ private static boolean isListRequiredType(java.lang.reflect.Type type) { return false; } + private static java.lang.reflect.Type getListRequiredType(java.lang.reflect.Type requiredType) { + if (requiredType instanceof ParameterizedType) { + final ParameterizedType parameterizedType = (ParameterizedType) requiredType; + if (List.class.equals(parameterizedType.getRawType())) { + // List -> String + return parameterizedType.getActualTypeArguments()[0]; + } + } + return null; + } + private static boolean isTypeArgumentInstanceHandle(java.lang.reflect.Type type) { // List -> String java.lang.reflect.Type typeArgument = ((ParameterizedType) type).getActualTypeArguments()[0]; diff --git a/test-framework/junit5-component/src/main/java/io/quarkus/test/component/SkipInject.java b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/SkipInject.java new file mode 100644 index 00000000000000..6fe5aecc3057bf --- /dev/null +++ b/test-framework/junit5-component/src/main/java/io/quarkus/test/component/SkipInject.java @@ -0,0 +1,16 @@ +package io.quarkus.test.component; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +/** + * Skip injection for the annotated test method parameter. + */ +@Retention(RUNTIME) +@Target(PARAMETER) +public @interface SkipInject { + +} diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/beans/MyComponent.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/beans/MyComponent.java index fa46c6ebba45b6..4f6c0ceea55ee3 100644 --- a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/beans/MyComponent.java +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/beans/MyComponent.java @@ -31,4 +31,8 @@ void onBoolean(@Observes Boolean payload, Delta delta) { delta.onBoolean(); } + public Charlie getCharlie() { + return charlie; + } + } diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/ListAllMockTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/ListAllMockTest.java index 5534cc64c707a9..8e9ca9f4b14565 100644 --- a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/ListAllMockTest.java +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/declarative/ListAllMockTest.java @@ -27,6 +27,10 @@ public class ListAllMockTest { @InjectMock Delta delta; + @Inject + @All + List deltas; + @InjectMock @SimpleQualifier Bravo bravo; @@ -38,6 +42,7 @@ public void testMock() { assertFalse(component.ping()); assertEquals(1, component.bravos.size()); assertEquals("ok", component.bravos.get(0).ping()); + assertEquals(deltas.get(0).ping(), component.ping()); } @Singleton diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionDependentTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionDependentTest.java new file mode 100644 index 00000000000000..86e134ca95a9a5 --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionDependentTest.java @@ -0,0 +1,45 @@ +package io.quarkus.test.component.paraminject; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicBoolean; + +import jakarta.annotation.PreDestroy; +import jakarta.enterprise.context.Dependent; + +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; + +import io.quarkus.test.component.QuarkusComponentTest; + +@QuarkusComponentTest +public class ParameterInjectionDependentTest { + + @Order(1) + @Test + public void testParamsInjection(MyDependent myDependent) { + assertTrue(myDependent.ping()); + } + + @Order(2) + @Test + public void testDependentDestroyed() { + assertTrue(MyDependent.DESTROYED.get()); + } + + @Dependent + public static class MyDependent { + + static final AtomicBoolean DESTROYED = new AtomicBoolean(); + + boolean ping() { + return true; + } + + @PreDestroy + void destroy() { + DESTROYED.set(true); + } + } + +} 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 new file mode 100644 index 00000000000000..af1edfe148e139 --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerClassLifecycleTest.java @@ -0,0 +1,46 @@ +package io.quarkus.test.component.paraminject; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.UUID; + +import jakarta.annotation.PostConstruct; +import jakarta.inject.Singleton; + +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 io.quarkus.test.component.QuarkusComponentTest; + +@TestInstance(Lifecycle.PER_CLASS) +@QuarkusComponentTest +public class ParameterInjectionPerClassLifecycleTest { + + static volatile String mySingletonId; + + @Order(1) + @Test + public void testSingleton1(MySingleton mySingleton) { + mySingletonId = mySingleton.id; + } + + @Order(2) + @Test + public void testSingleton2(MySingleton mySingleton) { + assertEquals(mySingletonId, mySingleton.id); + } + + @Singleton + public static class MySingleton { + + String id; + + @PostConstruct + void initId() { + id = UUID.randomUUID().toString(); + } + } + +} diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerMethodLifecycleTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerMethodLifecycleTest.java new file mode 100644 index 00000000000000..d14287e9578951 --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionPerMethodLifecycleTest.java @@ -0,0 +1,47 @@ +package io.quarkus.test.component.paraminject; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import java.util.UUID; + +import jakarta.annotation.PostConstruct; +import jakarta.inject.Singleton; + +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 io.quarkus.test.component.QuarkusComponentTest; + +@TestInstance(Lifecycle.PER_METHOD) +@QuarkusComponentTest +public class ParameterInjectionPerMethodLifecycleTest { + + static volatile String mySingletonId; + + @Order(1) + @Test + public void testSingleton1(MySingleton mySingleton) { + mySingletonId = mySingleton.id; + } + + @Order(2) + @Test + public void testSingleton2(MySingleton mySingleton) { + // Each test method starts a different CDI container + assertNotEquals(mySingletonId, mySingleton.id); + } + + @Singleton + public static class MySingleton { + + String id; + + @PostConstruct + void initId() { + id = UUID.randomUUID().toString(); + } + } + +} diff --git a/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionTest.java b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionTest.java new file mode 100644 index 00000000000000..8e81c48b72c00c --- /dev/null +++ b/test-framework/junit5-component/src/test/java/io/quarkus/test/component/paraminject/ParameterInjectionTest.java @@ -0,0 +1,84 @@ +package io.quarkus.test.component.paraminject; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.function.Supplier; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; +import org.mockito.Mockito; + +import io.quarkus.arc.All; +import io.quarkus.test.InjectMock; +import io.quarkus.test.component.QuarkusComponentTest; +import io.quarkus.test.component.SkipInject; +import io.quarkus.test.component.TestConfigProperty; +import io.quarkus.test.component.beans.Charlie; +import io.quarkus.test.component.beans.MyComponent; + +@QuarkusComponentTest +public class ParameterInjectionTest { + + @ExtendWith(MyParamResolver.class) + @TestConfigProperty(key = "foo", value = "BAZ") + @Test + public void testParamsInjection( + // TestInfo should be ignored automatically + TestInfo testInfo, + // MyComponent is automatically a component + MyComponent myComponent, + // This would be normally resolved by QuarkusComponentTest but is annotated with @SkipInject + @SkipInject MyComponent anotherComponent, + // Inject unconfigured mock + @InjectMock Charlie charlie, + // Note that @SkipInject is redundant in this case because the Supplier interface cannot be used as a class-based bean + // And so no matching bean exists + @SkipInject Supplier shouldBeTrue, + // @All List<> needs special handling + @All List allMyComponents) { + Mockito.when(charlie.ping()).thenReturn("foo"); + assertNotNull(testInfo); + assertEquals("foo and BAZ", myComponent.ping()); + assertNull(anotherComponent.getCharlie()); + assertEquals(1, allMyComponents.size()); + assertEquals(myComponent.ping(), allMyComponents.get(0).ping()); + assertEquals(Boolean.TRUE, shouldBeTrue.get()); + } + + public static class MyParamResolver implements ParameterResolver { + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return parameterContext.getParameter().getType().equals(Supplier.class) + || (parameterContext.getParameter().getType().equals(MyComponent.class) + && parameterContext.isAnnotated(SkipInject.class)); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + if (parameterContext.getParameter().getType().equals(Supplier.class)) { + return new Supplier() { + + @Override + public Object get() { + return Boolean.TRUE; + } + }; + } else { + return new MyComponent(); + } + } + + } + +}