diff --git a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonJunitExtension.java b/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonJunitExtension.java index bc7c1d7d6db..f225326c006 100644 --- a/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonJunitExtension.java +++ b/microprofile/testing/junit5/src/main/java/io/helidon/microprofile/testing/junit5/HelidonJunitExtension.java @@ -92,9 +92,8 @@ public class HelidonJunitExtension implements BeforeEachCallback, @Override public Object createTestInstance(TestInstanceFactoryContext fc, ExtensionContext context) { - // Use a proxy to start the container after the test instance creation - // The container is started lazily when invoking a method - // or when resolving parameters + // Instrument the test class + // Use a proxy to start the container lazily Class testClass = instrument(context.getRequiredTestClass(), List.of(), List.of(), (type, method) -> { // class context store specific to the intercepted method diff --git a/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestConfigDelegate.java b/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestConfigDelegate.java index d3644c1eeb8..f099301973a 100644 --- a/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestConfigDelegate.java +++ b/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestConfigDelegate.java @@ -39,7 +39,7 @@ /** * Config delegate. *

- * Also implements a {@link io.helidon.config.Config Helidon Config} delegate backed by a {@link io.helidon.config.spi.LazyConfigSource} + * Also implements a {@link Config Helidon Config} delegate backed by a {@link LazyConfigSource} * to support "just in time" caching when using {@link io.helidon.config.Config Helidon Config}. */ abstract class HelidonTestConfigDelegate implements org.eclipse.microprofile.config.Config, Config { @@ -47,7 +47,7 @@ abstract class HelidonTestConfigDelegate implements org.eclipse.microprofile.con private final LazyValue hdelegate = LazyValue.create(this::delegate0); private final Map> cache = new HashMap<>(); - /** + /* * Get the MicroProfile config delegate. * * @return delegate diff --git a/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestDescriptor.java b/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestDescriptor.java index 7aa55e55bb1..f518688da8d 100644 --- a/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestDescriptor.java +++ b/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestDescriptor.java @@ -139,14 +139,4 @@ Stream annotations(Class aTyp * @return annotations */ Stream annotations(Class aType); - - /** - * Test if an annotation of the given type is present. - * - * @param type annotation type - * @return {@code true} if found, {@code false} otherwise - */ - default boolean containsAnnotation(Class type) { - return annotations(type).findFirst().isPresent(); - } } diff --git a/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestInfo.java b/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestInfo.java index 11a020f68f2..51f900b1e8c 100644 --- a/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestInfo.java +++ b/microprofile/testing/testing/src/main/java/io/helidon/microprofile/testing/HelidonTestInfo.java @@ -42,25 +42,25 @@ public sealed interface HelidonTestInfo extends Heli /** * Create a new class info. * - * @param element class + * @param clazz class * @return ClassInfo */ - static ClassInfo classInfo(Class element) { - return classInfo(element, HelidonTestDescriptorImpl::new); + static ClassInfo classInfo(Class clazz) { + return classInfo(clazz, HelidonTestDescriptorImpl::new); } /** * Create a new class info. * - * @param element class + * @param clazz class * @param function descriptor factory * @return ClassInfo */ - static ClassInfo classInfo(Class element, Function, HelidonTestDescriptor>> function) { - Class clazz = Instrumented.unwrap(element); - return ClassInfo.CACHE.compute(clazz.getName(), (e, r) -> { + static ClassInfo classInfo(Class clazz, Function, HelidonTestDescriptor>> function) { + Class theClass = Instrumented.unwrap(clazz); + return ClassInfo.CACHE.compute(theClass.getName(), (e, r) -> { if (r == null || r.get() == null) { - return new SoftReference<>(new ClassInfo(function.apply(element))); + return new SoftReference<>(new ClassInfo(function.apply(clazz))); } return r; }).get(); @@ -95,15 +95,15 @@ static MethodInfo methodInfo(Method element, ClassInfo classInfo) { /** * Create a new method info. * - * @param element method + * @param method method * @param classInfo class info * @param function descriptor factory * @return MethodInfo */ - static MethodInfo methodInfo(Method element, ClassInfo classInfo, Function> function) { - return MethodInfo.CACHE.compute(MethodInfo.cacheKey(element, classInfo), (e, r) -> { + static MethodInfo methodInfo(Method method, ClassInfo classInfo, Function> function) { + return MethodInfo.CACHE.compute(MethodInfo.cacheKey(method, classInfo), (e, r) -> { if (r == null || r.get() == null) { - return new SoftReference<>(new MethodInfo(function.apply(element), classInfo)); + return new SoftReference<>(new MethodInfo(function.apply(method), classInfo)); } return r; }).get(); @@ -155,6 +155,24 @@ default Optional testMethod() { */ ClassInfo classInfo(); + /** + * Indicate if the container should be reset. + * For a class this is resolved via {@code HelidonTest#resetPerTest()}. + * For a method this is inferred if any of the following annotations is used: + *

+ * + * @return {@code true} if reset is required, {@code false} otherwise + */ + default boolean requiresReset() { + return false; + } + /** * Class info. */ @@ -180,6 +198,17 @@ public ClassInfo classInfo() { return this; } + /** + * Test if any method in the represented class is annotated with the given type. + * + * @param aType annotation type + * @return {@code true} if found, {@code false} otherwise + */ + public boolean isTestClass(Class aType) { + return Stream.of(element().getDeclaredMethods()) + .anyMatch(m -> m.isAnnotationPresent(aType)); + } + @Override public boolean equals(Object o) { if (this == o) { @@ -307,20 +336,7 @@ public Stream annotations(Class aType) { classInfo.annotations(aType)); } - /** - * Indicate if the container should be reset. - * For a class this is resolved via {@code HelidonTest#resetPerTest()}. - * For a method this is inferred if any of the following annotations is used: - * - * - * @return {@code true} if reset is required, {@code false} otherwise - */ + @Override public boolean requiresReset() { return classInfo.resetPerTest() || descriptor.configuration().isPresent() diff --git a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListener.java b/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListener.java index c344e528372..301b9191802 100644 --- a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListener.java +++ b/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListener.java @@ -16,47 +16,42 @@ package io.helidon.microprofile.testing.testng; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; import io.helidon.microprofile.testing.HelidonTestContainer; import io.helidon.microprofile.testing.HelidonTestInfo; import io.helidon.microprofile.testing.HelidonTestInfo.ClassInfo; import io.helidon.microprofile.testing.HelidonTestInfo.MethodInfo; import io.helidon.microprofile.testing.HelidonTestScope; +import io.helidon.microprofile.testing.Instrumented; import io.helidon.microprofile.testing.Proxies; import org.testng.IAlterSuiteListener; -import org.testng.IClass; import org.testng.IClassListener; -import org.testng.IConfigurationListener; -import org.testng.IInvokedMethod; -import org.testng.IInvokedMethodListener; import org.testng.ISuite; import org.testng.ISuiteListener; import org.testng.ITestClass; import org.testng.ITestListener; import org.testng.ITestNGMethod; -import org.testng.ITestResult; import org.testng.annotations.BeforeTest; import org.testng.annotations.Guice; +import org.testng.annotations.Test; import org.testng.xml.XmlClass; import org.testng.xml.XmlSuite; import org.testng.xml.XmlTest; -import static io.helidon.microprofile.testing.HelidonTestInfo.classInfo; -import static io.helidon.microprofile.testing.HelidonTestInfo.methodInfo; -import static io.helidon.microprofile.testing.Instrumented.instrument; - /** * A TestNG listener that integrates CDI with TestNG to support Helidon MP. *

- * This extension starts a CDI container and adds the test class as a bean with support for injection. The test class uses - * a CDI scope that follows the test lifecycle as defined by {@code TODO}. + * This extension starts a CDI container and adds the test class as a bean with support for injection. *

* The container is started lazily during test execution to ensure that it is started after all other extensions. *

@@ -91,12 +86,12 @@ * * @see HelidonTest */ -public class HelidonTestNgListener implements ITestListener, - IClassListener, - IInvokedMethodListener, - IConfigurationListener, - ISuiteListener, - IAlterSuiteListener { +public class HelidonTestNgListener extends HelidonTestNgListenerBase implements ITestListener, + IClassListener, + ISuiteListener, + IAlterSuiteListener { + + private static final Logger LOGGER = System.getLogger(HelidonTestNgListener.class.getName()); private static final List TYPE_ANNOTATIONS = List.of( Proxies.annotation(Guice.class, attr -> { @@ -106,12 +101,9 @@ public class HelidonTestNgListener implements ITestListener, return null; })); - private static final List> METHOD_EXCLUDES = List.of( - BeforeTest.class); - - // TODO remove thread local - private static final ThreadLocal CONTAINER = new ThreadLocal<>(); + private static final List> METHOD_EXCLUDES = List.of(BeforeTest.class); + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private final Map, HelidonTestContainer> containers = new ConcurrentHashMap<>(); @Override @@ -119,18 +111,22 @@ public void alter(List suites) { for (XmlSuite suite : suites) { for (XmlTest test : suite.getTests()) { for (XmlClass xmlClass : test.getXmlClasses()) { - ClassInfo classInfo = classInfo(xmlClass.getSupportClass(), HelidonTestDescriptorImpl::new); - if (Modifier.isAbstract(classInfo.element().getModifiers()) - || Modifier.isFinal(classInfo.element().getModifiers())) { - // TODO check if contains @Test - // TODO warning if final ? - continue; + ClassInfo classInfo = classInfo(xmlClass.getSupportClass()); + if (classInfo.isTestClass(Test.class)) { + Class testClass = classInfo.element(); + if (Modifier.isAbstract(testClass.getModifiers())) { + continue; + } + if (Modifier.isFinal(testClass.getModifiers())) { + LOGGER.log(Level.WARNING, "Cannot instrument final class: " + classInfo.id()); + continue; + } + // Instrument the test class + // Add a @Guice annotation to install HelidonTestNgModuleFactory + // Use a proxy to start the container lazily + xmlClass.setClass(Instrumented.instrument(testClass, + TYPE_ANNOTATIONS, METHOD_EXCLUDES, this::resolveInstance)); } - // Use a proxy to start the container after the test instance creation - // The container is started lazily when invoking a method - // We also add @Guice(moduleFactory = HelidonTestNgModuleFactory.class) - xmlClass.setClass(instrument(classInfo.element(), - TYPE_ANNOTATIONS, METHOD_EXCLUDES, this::testInstance)); } } } @@ -139,82 +135,77 @@ public void alter(List suites) { @Override public void onStart(ISuite suite) { for (ITestNGMethod tm : suite.getAllMethods()) { - // replace the test class with a decorator to customize the name + // replace the built-in ITestClass with a decorator // to hide the instrumented class name in the test results tm.setTestClass(TestClassDecorator.decorate(tm.getTestClass())); } } @Override - public void beforeInvocation(IInvokedMethod im, ITestResult tr) { - initContainer(tr, im.getTestMethod()); - } - - @Override - public void afterInvocation(IInvokedMethod im, ITestResult tr) { - HelidonTestInfo testInfo = testInfo(tr, im.getTestMethod()); - if (requiresReset(testInfo)) { - closeContainer(testInfo); + void onBeforeInvocation(ClassInfo classInfo, MethodInfo methodInfo, HelidonTestInfo testInfo) { + HelidonTestContainer container; + try { + lock.readLock().lock(); + + // current container for the test class + container = containers.get(classInfo); + + // close the container if the method requires a reset + if (methodInfo.requiresReset() && container != null) { + container.close(); + containers.remove(classInfo); + container = null; + } + } finally { + lock.readLock().unlock(); } - CONTAINER.remove(); - } - - @Override - public void onAfterClass(ITestClass tc) { - closeContainer(testInfo(tc)); - } - private T testInstance(Class type, Method method) { - HelidonTestContainer container = CONTAINER.get(); + // create the container if (container == null) { - throw new IllegalStateException("Container not set"); - } - return container.resolveInstance(type); - } - - private void initContainer(ITestResult tr, ITestNGMethod tm) { - HelidonTestInfo testInfo = testInfo(tr, tm); - HelidonTestContainer container = containers.compute(testInfo.classInfo(), - (i, c) -> { - boolean requireReset = requiresReset(testInfo); - if (requireReset && c != null) { - c.close(); - } - if (c == null || c.closed()) { - HelidonTestScope scope = HelidonTestScope.ofContainer(); - if (requireReset) { - c = new HelidonTestContainer(testInfo, scope, HelidonTestExtensionImpl::new); - } else { - c = new HelidonTestContainer(testInfo.classInfo(), scope, HelidonTestExtensionImpl::new); - } + try { + lock.writeLock().lock(); + container = containers.get(classInfo); + if (container == null) { + HelidonTestScope scope = HelidonTestScope.ofContainer(); + if (methodInfo.requiresReset()) { + container = new HelidonTestContainer(methodInfo, scope, HelidonTestExtensionImpl::new); + } else { + container = new HelidonTestContainer(classInfo, scope, HelidonTestExtensionImpl::new); } - return c; - }); - containers.putIfAbsent(testInfo, container); - CONTAINER.set(container); - } - - private void closeContainer(HelidonTestInfo testInfo) { - HelidonTestContainer container = containers.remove(testInfo); - if (container != null) { - container.close(); + containers.put(classInfo, container); + } + } finally { + lock.writeLock().unlock(); + } } + containers.putIfAbsent(methodInfo, container); + containers.putIfAbsent(testInfo, container); } - private HelidonTestInfo testInfo(ITestResult tr, ITestNGMethod tm) { - ClassInfo classInfo = testInfo(tr.getTestClass()); - return tm != null ? testInfo(tm.getConstructorOrMethod().getMethod(), classInfo) : classInfo; + @Override + void onAfterInvocation(MethodInfo methodInfo, HelidonTestInfo testInfo, boolean last) { + onAfter(methodInfo, last && testInfo.requiresReset()); } - private ClassInfo testInfo(IClass ic) { - return classInfo(ic.getRealClass(), HelidonTestDescriptorImpl::new); + @Override + public void onAfterClass(ITestClass tc) { + onAfter(classInfo(tc.getRealClass()), true); } - private MethodInfo testInfo(Method method, ClassInfo classInfo) { - return methodInfo(method, classInfo, HelidonTestDescriptorImpl::new); + private void onAfter(HelidonTestInfo testInfo, boolean closeContainer) { + containers.computeIfPresent(testInfo, (k, v) -> { + if (closeContainer) { + v.close(); + } + return null; + }); } - private static boolean requiresReset(HelidonTestInfo testInfo) { - return testInfo instanceof MethodInfo methodInfo && methodInfo.requiresReset(); + private T resolveInstance(Class type, Method method) { + HelidonTestContainer container = containers.get(methodInfo(type, method)); + if (container == null) { + throw new IllegalStateException("Container not set"); + } + return container.resolveInstance(type); } } diff --git a/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListenerBase.java b/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListenerBase.java new file mode 100644 index 00000000000..4195d19339f --- /dev/null +++ b/microprofile/testing/testng/src/main/java/io/helidon/microprofile/testing/testng/HelidonTestNgListenerBase.java @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.microprofile.testing.testng; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import io.helidon.microprofile.testing.HelidonTestInfo; +import io.helidon.microprofile.testing.HelidonTestInfo.ClassInfo; +import io.helidon.microprofile.testing.HelidonTestInfo.MethodInfo; + +import org.testng.IConfigurationListener; +import org.testng.IInvokedMethod; +import org.testng.IInvokedMethodListener; +import org.testng.ITestNGMethod; +import org.testng.ITestResult; + +/** + * Base listener that implements before/after invoke methods. + */ +class HelidonTestNgListenerBase implements IInvokedMethodListener, + IConfigurationListener { + + private final Map, List> methods = new ConcurrentHashMap<>(); + + /** + * Before invocation. + * + * @param classInfo test class info + * @param methodInfo invoked method info + * @param testInfo test info + */ + void onBeforeInvocation(ClassInfo classInfo, MethodInfo methodInfo, HelidonTestInfo testInfo) { + // no-op + } + + /** + * After invocation. + * + * @param methodInfo invoked method info + * @param testInfo test info + * @param last {@code true} if this method is the last one, {@code false} otherwise + */ + void onAfterInvocation(MethodInfo methodInfo, HelidonTestInfo testInfo, boolean last) { + // no-op + } + + @Override + public void beforeConfiguration(ITestResult tr, ITestNGMethod tm) { + ClassInfo classInfo = classInfo(tr); + MethodInfo methodInfo = methodInfo(classInfo, tr.getMethod()); + HelidonTestInfo testInfo = tm != null ? methodInfo(classInfo, tm) : classInfo; + methods.compute(testInfo, (k, v) -> { + if (v == null) { + v = new ArrayList<>(); + } + v.add(tm); + return v; + }); + onBeforeInvocation(classInfo, methodInfo, testInfo); + } + + @Override + public void onConfigurationFailure(ITestResult tr, ITestNGMethod tm) { + afterConfiguration(tr, tm); + } + + @Override + public void onConfigurationSuccess(ITestResult tr, ITestNGMethod tm) { + afterConfiguration(tr, tm); + } + + /** + * After configuration. + * + * @param tr test result + * @param tm test method, may be {@code null} + */ + void afterConfiguration(ITestResult tr, ITestNGMethod tm) { + ClassInfo classInfo = classInfo(tr); + MethodInfo methodInfo = methodInfo(classInfo, tr.getMethod()); + HelidonTestInfo testInfo = tm != null ? methodInfo(classInfo, tm) : classInfo; + List deps = methods.compute(testInfo, (k, v) -> { + if (v == null) { + return List.of(); + } + v.remove(tm); + return v; + }); + onAfterInvocation(methodInfo, testInfo, deps.isEmpty()); + } + + @Override + public void beforeInvocation(IInvokedMethod im, ITestResult tr) { + if (im.isTestMethod()) { + ClassInfo classInfo = classInfo(tr); + MethodInfo methodInfo = methodInfo(classInfo, im.getTestMethod()); + onBeforeInvocation(classInfo, methodInfo, methodInfo); + } + } + + @Override + public void afterInvocation(IInvokedMethod im, ITestResult tr) { + if (im.isTestMethod()) { + ClassInfo classInfo = classInfo(tr); + MethodInfo methodInfo = methodInfo(classInfo, im.getTestMethod()); + List deps = methods.getOrDefault(methodInfo, List.of()); + onAfterInvocation(methodInfo, methodInfo, deps.isEmpty()); + } + } + + /** + * Get a class info. + * + * @param clazz class + * @return ClassInfo + */ + static ClassInfo classInfo(Class clazz) { + return HelidonTestInfo.classInfo(clazz, HelidonTestDescriptorImpl::new); + } + + /** + * Get a class info. + * + * @param tr test result + * @return ClassInfo + */ + static ClassInfo classInfo(ITestResult tr) { + return classInfo(tr.getTestClass().getRealClass()); + } + + /** + * Get a method info. + * + * @param clazz class + * @param method method + * @return MethodInfo + */ + static MethodInfo methodInfo(Class clazz, Method method) { + return methodInfo(classInfo(clazz), method); + } + + /** + * Get a methodInfo info. + * + * @param classInfo class info + * @param tm method + * @return MethodInfo + */ + static MethodInfo methodInfo(ClassInfo classInfo, ITestNGMethod tm) { + return methodInfo(classInfo, tm.getConstructorOrMethod().getMethod()); + } + + /** + * Get a method info. + * + * @param classInfo class info + * @param method method + * @return MethodInfo + */ + static MethodInfo methodInfo(ClassInfo classInfo, Method method) { + return HelidonTestInfo.methodInfo(method, classInfo, HelidonTestDescriptorImpl::new); + } +} diff --git a/microprofile/testing/testng/src/main/java/module-info.java b/microprofile/testing/testng/src/main/java/module-info.java index b579de86b5d..1ad14b0b8c2 100644 --- a/microprofile/testing/testng/src/main/java/module-info.java +++ b/microprofile/testing/testng/src/main/java/module-info.java @@ -18,8 +18,6 @@ * TestNG extension module to run CDI tests. */ module io.helidon.microprofile.testing.testng { - uses org.testng.xml.ISuiteParser; - requires org.testng; requires transitive io.helidon.microprofile.testing; requires static io.helidon.microprofile.server;