This is an automated email from the ASF dual-hosted git repository. jsedding pushed a commit to branch feature/SLING-10497-junit-jupiter-parameter-resolver-for-osgi in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-junit-core.git
commit 15a57031cc1ab3fb10cdfebb9b522444062a2cde Author: Julian Sedding <jsedd...@apache.org> AuthorDate: Wed Jun 16 15:58:46 2021 +0200 SLING-10497 - JUnit Jupiter ParameterResolver for OSGi --- bnd.bnd | 3 + pom.xml | 25 +- .../junit5/JUnit5TestExecutionStrategy.java | 45 +-- .../impl/servlet/junit5/JUnitPlatformHelper.java | 117 ++++++ .../org/apache/sling/junit/jupiter/osgi/OSGi.java | 53 +++ .../apache/sling/junit/jupiter/osgi/Service.java | 70 ++++ .../apache/sling/junit/jupiter/osgi/Services.java | 36 ++ .../impl/AbstractTypeBasedParameterResolver.java | 66 ++++ .../osgi/impl/BundleContextParameterResolver.java | 33 ++ .../jupiter/osgi/impl/BundleParameterResolver.java | 33 ++ .../junit/jupiter/osgi/impl/ReflectionHelper.java | 103 ++++++ .../osgi/impl/ServiceParameterResolver.java | 263 ++++++++++++++ .../osgi/impl/TypeBasedParameterResolver.java | 53 +++ .../sling/junit/jupiter/osgi/package-info.java | 22 ++ .../sling/junit/impl/servlet/HtmlRendererTest.java | 16 +- .../junit/jupiter/osgi/OSGiAnnotationTest.java | 392 +++++++++++++++++++++ 16 files changed, 1271 insertions(+), 59 deletions(-) diff --git a/bnd.bnd b/bnd.bnd index aea6f89..a60cacf 100644 --- a/bnd.bnd +++ b/bnd.bnd @@ -1,8 +1,11 @@ Bundle-Activator: org.apache.sling.junit.Activator Export-Package: !org.junit.platform.*, \ + !org.junit.jupiter.*, \ junit.*;version=${junit.version}, \ org.junit.*;version=${junit.version}, \ org.hamcrest.*;version=${hamcrest.version};-split-package:=merge-first Import-Package: org.junit.platform.*;resolution:=optional, \ + org.junit.jupiter.*;resolution:=optional, \ + org.apache.commons.lang3.reflect.*;resolution:=optional, \ * -includeresource: @org.jacoco.agent-*.jar!/org/jacoco/agent/rt/IAgent* diff --git a/pom.xml b/pom.xml index 9e68ab1..e5f2f36 100644 --- a/pom.xml +++ b/pom.xml @@ -37,9 +37,10 @@ <junit.version>4.13</junit.version> <hamcrest.version>1.3</hamcrest.version> <jacoco.version>0.6.2.201302030002</jacoco.version> - <junit-jupiter.version>5.7.1</junit-jupiter.version> + <junit-jupiter.version>5.6.0</junit-jupiter.version> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> + <sonar.test.exclusions>**/*PseudoTest*</sonar.test.exclusions> </properties> <scm> @@ -282,7 +283,14 @@ <dependency> <groupId>org.apache.sling</groupId> <artifactId>org.apache.sling.commons.osgi</artifactId> - <version>2.2.2</version> + <version>2.4.2</version> + </dependency> + + <dependency> + <groupId>org.apache.commons</groupId> + <artifactId>commons-lang3</artifactId> + <version>3.0</version> + <scope>provided</scope> </dependency> <!-- optional imports for JUnit 5 support --> @@ -319,7 +327,7 @@ <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter-api</artifactId> <version>${junit-jupiter.version}</version> - <scope>test</scope> + <scope>provided</scope> </dependency> <dependency> @@ -345,8 +353,15 @@ <dependency> <groupId>org.mockito</groupId> - <artifactId>mockito-core</artifactId> - <version>3.5.7</version> + <artifactId>mockito-inline</artifactId> + <version>3.10.0</version> + <scope>test</scope> + </dependency> + + <dependency> + <groupId>org.apache.sling</groupId> + <artifactId>org.apache.sling.testing.osgi-mock.junit5</artifactId> + <version>3.1.2</version> <scope>test</scope> </dependency> </dependencies> diff --git a/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnit5TestExecutionStrategy.java b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnit5TestExecutionStrategy.java index 7363374..e39ed73 100644 --- a/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnit5TestExecutionStrategy.java +++ b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnit5TestExecutionStrategy.java @@ -21,22 +21,14 @@ package org.apache.sling.junit.impl.servlet.junit5; import org.apache.sling.junit.TestSelector; import org.apache.sling.junit.impl.TestExecutionStrategy; import org.apache.sling.junit.impl.TestsManagerImpl; -import org.jetbrains.annotations.NotNull; -import org.junit.platform.engine.DiscoverySelector; -import org.junit.platform.engine.TestEngine; -import org.junit.platform.engine.discovery.DiscoverySelectors; import org.junit.platform.launcher.Launcher; import org.junit.platform.launcher.LauncherDiscoveryRequest; -import org.junit.platform.launcher.core.LauncherConfig; -import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; -import org.junit.platform.launcher.core.LauncherFactory; import org.junit.runner.notification.RunListener; import org.osgi.framework.BundleContext; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.stream.Stream; import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; @@ -73,39 +65,10 @@ public class JUnit5TestExecutionStrategy implements TestExecutionStrategy { @Override public void execute(TestSelector selector, RunListener runListener) throws Exception { - Launcher launcher = createLauncher(runListener, testEngineTracker.getAvailableTestEngines()); + Launcher launcher = JUnitPlatformHelper.createLauncher(testEngineTracker.getAvailableTestEngines()); final LauncherDiscoveryRequest request = testsManager.createTestRequest(selector, - JUnit5TestExecutionStrategy::methodRequest, - JUnit5TestExecutionStrategy::classesRequest); - launcher.execute(request); - } - - @NotNull - public static Launcher createLauncher(RunListener runListener, TestEngine... availableTestEngines) { - return LauncherFactory.create( - LauncherConfig.builder() - .addTestEngines(availableTestEngines) - .addTestExecutionListeners(new RunListenerAdapter(runListener)) - .enableTestEngineAutoRegistration(false) - .enableTestExecutionListenerAutoRegistration(false) - .build() - ); - } - - @NotNull - public static LauncherDiscoveryRequest methodRequest(Class<?> testClass, String testMethodName) { - return LauncherDiscoveryRequestBuilder.request() - .selectors(selectMethod(testClass, testMethodName)) - .build(); - } - - @NotNull - public static LauncherDiscoveryRequest classesRequest(Class<?>... testClasses) { - final DiscoverySelector[] selectors = Stream.of(testClasses) - .map(DiscoverySelectors::selectClass) - .toArray(DiscoverySelector[]::new); - return LauncherDiscoveryRequestBuilder.request() - .selectors(selectors) - .build(); + JUnitPlatformHelper::methodRequest, + JUnitPlatformHelper::classesRequest); + launcher.execute(request, new RunListenerAdapter(runListener)); } } diff --git a/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnitPlatformHelper.java b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnitPlatformHelper.java new file mode 100644 index 0000000..f6c0312 --- /dev/null +++ b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnitPlatformHelper.java @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.impl.servlet.junit5; + +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.platform.commons.util.ReflectionUtils; +import org.junit.platform.engine.DiscoverySelector; +import org.junit.platform.engine.TestEngine; +import org.junit.platform.engine.discovery.DiscoverySelectors; +import org.junit.platform.launcher.Launcher; +import org.junit.platform.launcher.LauncherDiscoveryRequest; +import org.junit.platform.launcher.TestExecutionListener; +import org.junit.platform.launcher.core.LauncherConfig; +import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder; +import org.junit.platform.launcher.core.LauncherFactory; + +import java.util.Objects; +import java.util.stream.Stream; + +import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod; + +/** + * Utilities for running tests via the JUnit Platform. I.e. depending on the supplied {@link TestEngine}(s), + * it is possible to run JUnit3, JUnit4 or Jupiter based tests using {@code TestEngines} supplied by + * the JUnit team. No issues are expected running bespoke {@code TestEngines}. + */ +public final class JUnitPlatformHelper { + + /** + * Execute a test class (if {@code testMethodName == null}) or a single test method with a specified {@code TestEngine}. + * All provided {@code TestExecutionListener}s are registered to be notified of the test's execution. + * + * @param testEngine a {@code TestEngine} instance + * @param testClass a test class that can be executed by the given {@code TestEngine} + * @param testMethodName the name of a test method in the given test class or null to run all test methods + * @param listeners any number of {@code TestExecutionListener}s that should be notified + */ + public static void executeTest(@NotNull TestEngine testEngine, @NotNull Class<?> testClass, @Nullable String testMethodName, @NotNull TestExecutionListener... listeners) { + final Launcher launcher = JUnitPlatformHelper.createLauncher(testEngine); + final LauncherDiscoveryRequest request = testMethodName != null + ? JUnitPlatformHelper.methodRequest(testClass, testMethodName) + : JUnitPlatformHelper.classesRequest(testClass); + launcher.execute(request, listeners); + } + + /** + * Utility method to create a {@link Launcher} for the given {@code TestEngines} only, without + * any automatically registered {@code TestEngines} or {@code TestExecutionListeners}. + * + * @param testEngines The test engines available to the {@code Launcher} instance. + * @return A JUnit Platform {@code Launcher} instance. + */ + @NotNull + public static Launcher createLauncher(TestEngine... testEngines) { + return LauncherFactory.create(LauncherConfig.builder() + .enableTestEngineAutoRegistration(false) + .enableTestExecutionListenerAutoRegistration(false) + .addTestEngines(testEngines) + .build()); + } + + /** + * Utility to create a {@link LauncherDiscoveryRequest} for a particular test method, specified by the + * test class and the test method's name. If multiple overloaded test methods with different parameters + * exist, they would all be executed. + * + * @param testClass a test class + * @param testMethodName the name of a test method in the given test class or null to run all test methods + * @return a {@code LauncherDiscoveryRequest} representing the specified test method. + */ + @NotNull + public static LauncherDiscoveryRequest methodRequest(Class<?> testClass, String testMethodName) { + final LauncherDiscoveryRequestBuilder requestBuilder = LauncherDiscoveryRequestBuilder.request(); + ReflectionUtils.findMethods(testClass, method -> Objects.equals(method.getName(), testMethodName)).stream() + .map(method -> selectMethod(testClass, method)) + .forEach(requestBuilder::selectors); + return requestBuilder.build(); + } + + + /** + * Utility to create a {@link LauncherDiscoveryRequest} for all test methods of the specified test class(es). + * + * @param testClasses a number of test classes + * @return a {@code LauncherDiscoveryRequest} representing the specified test classes. + */ + @NotNull + public static LauncherDiscoveryRequest classesRequest(Class<?>... testClasses) { + final DiscoverySelector[] selectors = Stream.of(testClasses) + .map(DiscoverySelectors::selectClass) + .toArray(DiscoverySelector[]::new); + return LauncherDiscoveryRequestBuilder.request() + .selectors(selectors) + .build(); + } + + private JUnitPlatformHelper() { + // no instances + } +} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/OSGi.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/OSGi.java new file mode 100644 index 0000000..f5a07ae --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/OSGi.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi; + +import org.apache.sling.junit.jupiter.osgi.impl.BundleContextParameterResolver; +import org.apache.sling.junit.jupiter.osgi.impl.BundleParameterResolver; +import org.apache.sling.junit.jupiter.osgi.impl.ServiceParameterResolver; +import org.junit.jupiter.api.extension.ExtendWith; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * OSGi test annotation, for running unit tests within OSGi frameworks. The annotation supports + * injecting {@link Bundle}, {@link BundleContext} and service instances in conjunction with the + * {@link Service @Service} annotation. The annotation can be used on test classes or on individual + * test methods. If used on test classes injection of constructor parameters is supported in addition + * to injection of method parameters. + * <br> + * Note: the implementation relies on calling {@link FrameworkUtil#getBundle(Class)} with the test class + * in order to gain access to the world of OSGi. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD}) +@ExtendWith({ + BundleParameterResolver.class, + BundleContextParameterResolver.class, + ServiceParameterResolver.class +}) +@Inherited +public @interface OSGi {} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/Service.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/Service.java new file mode 100644 index 0000000..3384a37 --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/Service.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi; + +import org.osgi.framework.Filter; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * The {@code @Service} annotation is to be used for test classes or methods annotated + * with the {@link OSGi @OSGi} annotation. Note that tests using this annotation are + * expected to be run within an OSGi environment. It is a repeatable annotation and can + * be used to specify a number of different services using the {@link #value() service + * type} and an optional {@link #filter() LDAP filter expression}. + * <br> + * Supported parameter types ar the service type itself for mandatory and unary references (1..1), + * a {@code Collection} or {@code List} of the service type for optional and multiple references (0..n). + * Currently no other cardinalities are supported. + * <br> + * When used on a test class, the specified services are made available for injection as parameters + * to all of the test's methods. + * <br> + * When used on a test method, the specified services are made available for injection as parameters + * to exactly that method. + * <br> + * When used on a method parameter, the specified service is made available for injection for exactly + * that parameter. In this case, the {@link #value() service type} need not be specified, it can + * be inferred from the parameter's type. However, it may still be useful to specify a filter expression. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER}) +@Repeatable(Services.class) +@Inherited +public @interface Service { + + /** + * The type of the service to be injected. + * <br> + * May be omitted if the annotation is used to annotate a method parameter, as the service type can + * be inferred from the parameter's type. + */ + Class<?> value() default Object.class; + + /** + * An optional filter expression conforming to the LDAP filter syntax used in OSGi {@link Filter}s. + */ + String filter() default ""; +} + diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/Services.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/Services.java new file mode 100644 index 0000000..477c12f --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/Services.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Utility annotation used to allow the {@link Service @Service} annotation to be repeatable. + * It is possible, but unnecessary, to use this annotation directly. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER}) +@Inherited +public @interface Services { + Service[] value() default {}; +} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/AbstractTypeBasedParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/AbstractTypeBasedParameterResolver.java new file mode 100644 index 0000000..c2817fe --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/AbstractTypeBasedParameterResolver.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi.impl; + +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolver; + +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.util.Map; + +import static org.apache.sling.junit.jupiter.osgi.impl.ReflectionHelper.determineTypeArguments; + +/** + * Abstract {@link ParameterResolver} class that resolves any type-arguments in the parameter's type + * to their actual type and provides this {@code resolvedParameterType} to the abstract methods + * {@link #supportsParameter(ParameterContext, ExtensionContext, Type)} and + * {@link #resolveParameter(ParameterContext, ExtensionContext, Type)}. + */ +public abstract class AbstractTypeBasedParameterResolver implements ParameterResolver { + + protected abstract boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType); + + protected abstract Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType); + + @Override + public final boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + final Type type = getTypeOfParameter(parameterContext, extensionContext); + return supportsParameter(parameterContext, extensionContext, type); + } + + @Override + public final Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + final Type typeOfParameter = getTypeOfParameter(parameterContext, extensionContext); + return resolveParameter(parameterContext, extensionContext, typeOfParameter); + } + + @NotNull + private static Type getTypeOfParameter(ParameterContext parameterContext, ExtensionContext extensionContext) { + Type type = parameterContext.getParameter().getParameterizedType(); + if (type instanceof TypeVariable) { + final Map<TypeVariable<?>, Type> typeVariableTypeMap = determineTypeArguments(extensionContext.getRequiredTestClass()); + return typeVariableTypeMap.getOrDefault((TypeVariable<?>) type, type); + } else { + return type; + } + } +} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/BundleContextParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/BundleContextParameterResolver.java new file mode 100644 index 0000000..a77b747 --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/BundleContextParameterResolver.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi.impl; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; + +import java.lang.reflect.Type; + +public class BundleContextParameterResolver extends TypeBasedParameterResolver<BundleContext> { + @Override + protected BundleContext resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType) { + return FrameworkUtil.getBundle(extensionContext.getRequiredTestClass()).getBundleContext(); + } +} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/BundleParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/BundleParameterResolver.java new file mode 100644 index 0000000..4bcc7e1 --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/BundleParameterResolver.java @@ -0,0 +1,33 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi.impl; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.osgi.framework.Bundle; +import org.osgi.framework.FrameworkUtil; + +import java.lang.reflect.Type; + +public class BundleParameterResolver extends TypeBasedParameterResolver<Bundle> { + @Override + protected Bundle resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType) { + return FrameworkUtil.getBundle(extensionContext.getRequiredTestClass()); + } +} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/ReflectionHelper.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/ReflectionHelper.java new file mode 100644 index 0000000..8ed7fe9 --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/ReflectionHelper.java @@ -0,0 +1,103 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi.impl; + +import org.apache.commons.lang3.reflect.TypeUtils; +import org.jetbrains.annotations.NotNull; +import org.junit.platform.commons.util.Preconditions; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Utility class used for resolving type-arguments to concrete types. + */ +public class ReflectionHelper { + + public static Map<TypeVariable<?>, Type> determineTypeArguments(@NotNull Class<?> clazz) { + final Map<TypeVariable<?>, Type> typeVariableTypeMap = new HashMap<>(); + determineTypeArguments(clazz, typeVariableTypeMap); + return Collections.unmodifiableMap(typeVariableTypeMap); + } + + private static void determineTypeArguments(Class<?> clazz, Map<TypeVariable<?>, Type> typeVariableTypeMap) { + final Type genericSuperclass = clazz.getGenericSuperclass(); + if (genericSuperclass instanceof ParameterizedType) { + final ParameterizedType parameterizedType = (ParameterizedType) genericSuperclass; + typeVariableTypeMap.putAll(TypeUtils.determineTypeArguments(clazz, parameterizedType)); + + final Type rawType = parameterizedType.getRawType(); + if (!(rawType instanceof Class<?>)) { + throw new UnsupportedOperationException("Expected Class#getGenericSuperclass() to return an object of type Class<?>"); + } + determineTypeArguments((Class<?>) rawType, typeVariableTypeMap); + } else if (genericSuperclass instanceof Class<?>) { + if (genericSuperclass == Object.class && ((Class<?>) genericSuperclass).isArray()) { + // TODO: handle array types, see docs for Class#getGenericSuperclass() + throw new UnsupportedOperationException("Unsupported case where genericSuperclass == Object.class"); + } else { + determineTypeArguments((Class<?>) genericSuperclass, typeVariableTypeMap); + } + } else if (genericSuperclass == null) { + final Type[] genericInterfaces = clazz.getGenericInterfaces(); + for (Type genericInterface : genericInterfaces) { + if (genericInterface instanceof ParameterizedType) { + final ParameterizedType parameterizedType = (ParameterizedType) genericInterface; + typeVariableTypeMap.putAll(TypeUtils.determineTypeArguments(clazz, parameterizedType)); + } + } + } else { + throw new UnsupportedOperationException("Expected Class#getGenericSuperclass() to return null or an object of type Class<?> or ParameterizedType"); + } + } + + + @NotNull + public static ParameterizedType parameterizedTypeForBaseClass(@NotNull Class<?> baseClass, @NotNull Class<?> clazz) { + ParameterizedType parameterizedType = findParameterizedTypeForBaseClass(baseClass, clazz); + Preconditions.notNull(parameterizedType, + () -> String.format( + "Failed to discover type supported by %s; " + + "potentially caused by lacking parameterized type in class declaration.", + clazz.getName())); + return parameterizedType; + } + + private static ParameterizedType findParameterizedTypeForBaseClass(Class<?> baseClass, Class<?> clazz) { + Class<?> superclass = clazz.getSuperclass(); + + // Abort? + if (superclass == null || superclass == Object.class) { + return null; + } + + Type genericSuperclass = clazz.getGenericSuperclass(); + if (genericSuperclass instanceof ParameterizedType) { + Type rawType = ((ParameterizedType) genericSuperclass).getRawType(); + if (rawType == baseClass) { + return ((ParameterizedType) genericSuperclass); + } + } + return findParameterizedTypeForBaseClass(baseClass, superclass); + } +} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/ServiceParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/ServiceParameterResolver.java new file mode 100644 index 0000000..05a2af2 --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/ServiceParameterResolver.java @@ -0,0 +1,263 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi.impl; + +import org.apache.sling.junit.jupiter.osgi.Service; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.platform.commons.support.AnnotationSupport; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.Constants; +import org.osgi.framework.Filter; +import org.osgi.framework.FrameworkUtil; +import org.osgi.framework.InvalidSyntaxException; +import org.osgi.framework.ServiceReference; +import org.osgi.util.tracker.ServiceTracker; + +import java.lang.reflect.AnnotatedElement; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.junit.platform.commons.support.AnnotationSupport.findAnnotation; + +public class ServiceParameterResolver extends AbstractTypeBasedParameterResolver { + + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(ServiceParameterResolver.class); + + @Override + protected boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType) { + return computeServiceType(resolvedParameterType) + .flatMap(serviceType -> findServiceAnnotation(parameterContext, extensionContext, serviceType)) + .isPresent(); + } + + @Override + protected Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType) { + return computeServiceType(resolvedParameterType) + .map(serviceType -> { + final Optional<Service> serviceAnnotation = findServiceAnnotation(parameterContext, extensionContext, serviceType); + return serviceAnnotation + .map(ann -> toKey(serviceType, ann)) + .map(key -> extensionContext.getStore(NAMESPACE) + .getOrComputeIfAbsent(key, serviceHolderFactory(extensionContext, serviceType), ServiceHolder.class)) + .map(serviceHolder -> isMultiple(resolvedParameterType) ? serviceHolder.getServices() : serviceHolder.getService()) + .orElseThrow(() -> createServiceNotFoundException(serviceAnnotation.map(Service::filter).orElse(null), serviceType)); + }) + .orElseThrow(() -> new ParameterResolutionException("Cannot handle type " + resolvedParameterType)); + } + + private static ServiceHolder.Key toKey(Class<?> serviceType, Service serviceAnnotation) { + return new ServiceHolder.Key(serviceType, serviceAnnotation); + } + + @NotNull + private static Optional<Class<?>> computeServiceType(Type resolvedParameterType) { + if (resolvedParameterType instanceof ParameterizedType) { + final ParameterizedType parameterizedType = (ParameterizedType) resolvedParameterType; + final Class<?> clazz = getRawClass(parameterizedType); + if (Collection.class == clazz || List.class.isAssignableFrom(clazz)) { + final Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + if (actualTypeArguments.length == 1 && actualTypeArguments[0] instanceof Class<?>) { + return Optional.of((Class<?>) actualTypeArguments[0]); + } + } + } else if (resolvedParameterType instanceof Class<?>) { + return Optional.of((Class<?>) resolvedParameterType); + } + return Optional.empty(); + } + + @NotNull + private static Class<?> getRawClass(ParameterizedType parameterizedType) { + final Type rawType = parameterizedType.getRawType(); + if (!(rawType instanceof Class<?>)) { + throw new UnsupportedOperationException("Unexpected raw type of parametereized type " + parameterizedType + ": " + rawType); + } + return (Class<?>) rawType; + } + + private ParameterResolutionException createServiceNotFoundException(String ldapFilter, Type resolvedParameterType) { + return Optional.ofNullable(ldapFilter) + .map(String::trim) + .filter(filter -> !filter.isEmpty()) + .map(filter -> new ParameterResolutionException("No service of type " + resolvedParameterType + " with filter \"" + filter + "\" available")) + .orElseGet(() -> new ParameterResolutionException("No service of type " + resolvedParameterType + " available")); + } + + @NotNull + private static Function<ServiceHolder.Key, ServiceHolder> serviceHolderFactory(ExtensionContext extensionContext, Class<?> requiredServiceType) { + return key -> new ServiceHolder(getBundleContext(extensionContext), key); + } + + @Nullable + private static BundleContext getBundleContext(ExtensionContext extensionContext) { + return Optional.ofNullable(FrameworkUtil.getBundle(extensionContext.getRequiredTestClass())) + .map(Bundle::getBundleContext) + .orElse(null); + } + + @NotNull + private static Optional<Service> findServiceAnnotation(ParameterContext parameterContext, ExtensionContext extensionContext, Class<?> requiredServiceType) { + return Stream.<Supplier<Optional<Service>>>of( + () -> findServiceAnnotationOnParameter(parameterContext, requiredServiceType), + () -> findServiceAnnotationOnMethodOrClass(extensionContext, requiredServiceType)) + .map(Supplier::get) + .filter(Optional::isPresent) + .map(Optional::get) + .findFirst(); + } + + private static Optional<Service> findServiceAnnotationOnMethodOrClass(ExtensionContext extensionContext, Class<?> requiredServiceType) { + return extensionContext.getElement() + .map(ae -> findMatchingServiceAnnotation(ae, requiredServiceType)) + .filter(Optional::isPresent) + .orElseGet(() -> extensionContext.getParent().flatMap(p -> findServiceAnnotationOnMethodOrClass(p, requiredServiceType))); + } + + private static Optional<Service> findServiceAnnotationOnParameter(ParameterContext parameterContext, Class<?> requiredServiceType) { + final Optional<Service> serviceAnnotation = findAnnotation(parameterContext.getParameter(), Service.class); + serviceAnnotation.ifPresent(ann -> { + if (!ann.value().isAssignableFrom(requiredServiceType)) { + throw new ParameterResolutionException("Mismatched types in annotation and parameter. " + + "Annotation type is \"" + ann.value().getSimpleName() + "\", parameter type is \"" + requiredServiceType.getSimpleName() + "\""); + } + }); + return serviceAnnotation; + } + + private static Optional<Service> findMatchingServiceAnnotation(AnnotatedElement annotatedElement, Class<?> requiredServiceType) { + return AnnotationSupport.findRepeatableAnnotations(annotatedElement, Service.class) + .stream() + .filter(serviceAnnotation -> Objects.equals(serviceAnnotation.value(), requiredServiceType)) + .findFirst(); + } + + private boolean isMultiple(Type resolvedParameterType) { + if (resolvedParameterType instanceof ParameterizedType) { + final Class<?> type = getRawClass((ParameterizedType) resolvedParameterType); + return Collection.class == type || List.class.isAssignableFrom(type); + } + return false; + } + + private static class ServiceHolder implements ExtensionContext.Store.CloseableResource { + + private final ServiceTracker<?, ?> serviceTracker; + + private ServiceHolder(BundleContext bundleContext, Key key) { + final Filter filter = createFilter(bundleContext, key.type(), key.filter()); + serviceTracker = new SortingServiceTracker<>(bundleContext, filter); + serviceTracker.open(); + } + + @Override + public void close() throws Throwable { + serviceTracker.close(); + } + + public Object getService() { + return serviceTracker.getService(); + } + + public List<Object> getServices() { + final Object[] services = serviceTracker.getServices(); + return services == null ? Collections.emptyList() : Arrays.asList(services); + } + + private static Filter createFilter(BundleContext bundleContext, Class<?> clazz, String ldapFilter) { + final String classFilter = String.format("(%s=%s)", Constants.OBJECTCLASS, clazz.getName()); + final String combinedFilter; + if (ldapFilter == null || ldapFilter.trim().isEmpty()) { + combinedFilter = classFilter; + } else { + combinedFilter = String.format("(&%s%s)", classFilter, ldapFilter); + } + try { + return bundleContext.createFilter(combinedFilter); + } catch (InvalidSyntaxException e) { + throw new IllegalArgumentException("Invalid filter expression: \"" + ldapFilter + "\"", e); + } + } + + private static class SortingServiceTracker<T> extends ServiceTracker<T, T> { + public SortingServiceTracker(BundleContext bundleContext, Filter filter) { + super(bundleContext, filter, null); + } + + @Override + public ServiceReference<T>[] getServiceReferences() { + return Optional.ofNullable(super.getServiceReferences()) + .map(serviceReferences -> { + Arrays.sort(serviceReferences); + return serviceReferences; + }) + .orElse(null); + } + } + + private static class Key { + + private final Class<?> serviceType; + + private final Service serviceAnnotation; + + public Key(Class<?> serviceType, Service serviceAnnotation) { + this.serviceType = serviceType; + this.serviceAnnotation = serviceAnnotation; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Key)) { + return false; + } + Key key = (Key) o; + return this == o + || (Objects.equals(serviceType, key.serviceType) + && Objects.equals(serviceAnnotation, key.serviceAnnotation)); + } + + @Override + public int hashCode() { + return Objects.hash(serviceType, serviceAnnotation); + } + + public Class<?> type() { + return serviceType; + } + + public String filter() { + return serviceAnnotation.filter(); + } + } + } +} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/TypeBasedParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/TypeBasedParameterResolver.java new file mode 100644 index 0000000..a8ec0d2 --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/TypeBasedParameterResolver.java @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi.impl; + +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +import static org.apache.sling.junit.jupiter.osgi.impl.ReflectionHelper.parameterizedTypeForBaseClass; + +/** + * Abstract implementation of a {@link org.junit.jupiter.api.extension.ParameterResolver} that resolves + * parameters of one given type. Implementations need only implement the abstract method + * {@link #resolveParameter(ParameterContext, ExtensionContext, Type)}, the supported parameter type is + * inferred from the classes type-argument {@code T}. + * + * @param <T> + */ +public abstract class TypeBasedParameterResolver<T> extends AbstractTypeBasedParameterResolver { + + private final Type supportedType; + + public TypeBasedParameterResolver() { + ParameterizedType parameterizedType = parameterizedTypeForBaseClass(TypeBasedParameterResolver.class, getClass()); + this.supportedType = parameterizedType.getActualTypeArguments()[0]; + } + + @Override + protected boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType) { + return supportedType == resolvedParameterType; + } + + @Override + protected abstract T resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType); +} diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/package-info.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/package-info.java new file mode 100644 index 0000000..83d36bf --- /dev/null +++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/package-info.java @@ -0,0 +1,22 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ +@Version("1.0.0") +package org.apache.sling.junit.jupiter.osgi; + +import org.osgi.annotation.versioning.Version; \ No newline at end of file diff --git a/src/test/java/org/apache/sling/junit/impl/servlet/HtmlRendererTest.java b/src/test/java/org/apache/sling/junit/impl/servlet/HtmlRendererTest.java index dd86d3d..c175746 100644 --- a/src/test/java/org/apache/sling/junit/impl/servlet/HtmlRendererTest.java +++ b/src/test/java/org/apache/sling/junit/impl/servlet/HtmlRendererTest.java @@ -18,7 +18,8 @@ */ package org.apache.sling.junit.impl.servlet; -import org.apache.sling.junit.impl.servlet.junit5.JUnit5TestExecutionStrategy; +import org.apache.sling.junit.impl.servlet.junit5.JUnitPlatformHelper; +import org.apache.sling.junit.impl.servlet.junit5.RunListenerAdapter; import org.hamcrest.Matchers; import org.junit.Assert; import org.junit.Assume; @@ -29,9 +30,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.junit.platform.engine.TestEngine; -import org.junit.platform.launcher.Launcher; -import org.junit.platform.launcher.LauncherDiscoveryRequest; -import org.junit.runner.notification.RunListener; import org.junit.vintage.engine.VintageTestEngine; import java.io.PrintWriter; @@ -86,18 +84,10 @@ public class HtmlRendererTest { final StringWriter out = new StringWriter(); final HtmlRenderer htmlRenderer = new HtmlRenderer(); htmlRenderer.setWriter(new PrintWriter(out)); - runTest(testEngine, htmlRenderer, testClass, methodName); + JUnitPlatformHelper.executeTest(testEngine, testClass, methodName, new RunListenerAdapter(htmlRenderer)); return out.toString(); } - private static void runTest(TestEngine testEngine, RunListener runListener, Class<?> testClass, String methodName) { - final Launcher launcher = JUnit5TestExecutionStrategy.createLauncher(runListener, testEngine); - final LauncherDiscoveryRequest request = methodName != null - ? JUnit5TestExecutionStrategy.methodRequest(testClass, methodName) - : JUnit5TestExecutionStrategy.classesRequest(testClass); - launcher.execute(request); - } - public static class ExampleTestCases { public static final String ASSUMPTION_IS_ALWAYS_INVALID = "Assumption is always invalid"; diff --git a/src/test/java/org/apache/sling/junit/jupiter/osgi/OSGiAnnotationTest.java b/src/test/java/org/apache/sling/junit/jupiter/osgi/OSGiAnnotationTest.java new file mode 100644 index 0000000..e315622 --- /dev/null +++ b/src/test/java/org/apache/sling/junit/jupiter/osgi/OSGiAnnotationTest.java @@ -0,0 +1,392 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.sling.junit.jupiter.osgi; + +import org.apache.sling.junit.impl.servlet.junit5.JUnitPlatformHelper; +import org.apache.sling.testing.mock.osgi.junit5.OsgiContext; +import org.apache.sling.testing.mock.osgi.junit5.OsgiContextExtension; +import org.hamcrest.Matchers; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.engine.JupiterTestEngine; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.platform.commons.annotation.Testable; +import org.junit.platform.commons.support.HierarchyTraversalMode; +import org.junit.platform.launcher.listeners.SummaryGeneratingListener; +import org.junit.platform.launcher.listeners.TestExecutionSummary; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.opentest4j.MultipleFailuresError; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.framework.FrameworkUtil; + +import java.lang.reflect.Method; +import java.lang.reflect.ParameterizedType; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.Arrays.asList; +import static org.apache.sling.junit.jupiter.osgi.impl.ReflectionHelper.parameterizedTypeForBaseClass; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.greaterThan; +import static org.hamcrest.Matchers.instanceOf; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedMethods; + +/** + * This test executes pseudo test classes using the {@link JupiterTestEngine} in order to + * verify the correct injection of parameters via the {@code @OSGi} and {@code @Service} + * annotations. + * <br> + * In order to achieve this, test methods from the pseudo test classes ({@code PseudoTest*} + * are executed and the test summary evaluated to verify expectations. The additional indirection + * can be a little tricky, but is necessary to test the annotations work correctly. Particularly + * when testing failure scenarios, where the failure of a pseudo test is required to pass the actual + * test. + */ +@ExtendWith(OsgiContextExtension.class) +public class OSGiAnnotationTest { + + private static final JupiterTestEngine JUPITER_TEST_ENGINE = new JupiterTestEngine(); + + OsgiContext osgiContext = new OsgiContext(); + + @SuppressWarnings("unused") // provides parameters + static Stream<Arguments> frameworkObjectsInjectionTests() { + return Stream.of(PseudoTestBundleInjection.class, PseudoTestBundleContextInjection.class) + .flatMap(OSGiAnnotationTest::allTestMethods); + } + + @ParameterizedTest(name = "{0}#{2}") + @MethodSource("frameworkObjectsInjectionTests") + void injectFrameworkObjects(String name, Class<?> testClass, String testMethodName) { + withMockedFrameworkUtil(() -> { + assertNoFailures(testClass, testMethodName); + }); + } + + @SuppressWarnings("unused") // provides parameters + static Stream<Arguments> serviceInjectionTests() { + return Stream + .of( + PseudoTestServiceInjectionGloballyAnnotated.class, + PseudoTestServiceInjectionGloballyAnnotatedWithFilter.class, + PseudoTestInheritedServiceInjectionGloballyAnnotated.class) + .flatMap(OSGiAnnotationTest::allTestMethods); + } + + @ParameterizedTest(name = "{0}#{2}") + @MethodSource("serviceInjectionTests") + void injectServices(String name, Class<?> testClass, String testMethodName) { + osgiContext.registerService(ServiceInterface.class, new ServiceA(), "foo", "quz"); + withMockedFrameworkUtil(() -> { + assertNoFailures(testClass, testMethodName); + }); + } + + @SuppressWarnings("unused") // provides parameters + static Stream<Arguments> failConstructionDueToMissingServiceInjectionTests() { + return Stream.of(PseudoTestServiceInjectionNotAnnotated.class, PseudoTestServiceInjectionGloballyAnnotatedWithFilter.class) + .flatMap(namedMethods("injectedConstructorParameter")); + } + + @ParameterizedTest(name = "{0}#{2}") + @MethodSource("failConstructionDueToMissingServiceInjectionTests") + void failConstructionDueToMissingServiceInjection(String name, Class<?> testClass, String testMethodName) { + // setup service with non-matching filter + osgiContext.registerService(ServiceInterface.class, new ServiceA(), "foo", "no match"); + withMockedFrameworkUtil(() -> { + assertTestConstructionFailsDueToMissingService(testClass, testMethodName); + }); + } + + @Test + void injectServiceAsAnnotatedMethodParameterWithExplicitClass() { + osgiContext.registerService(ServiceInterface.class, new ServiceA()); + withMockedFrameworkUtil(() -> { + assertNoFailures(PseudoTestServiceMethodInjection.class, "annotatedParameterWithExplicitClass"); + }); + } + + @Test + void injectServiceAsAnnotatedMethodParameterWithImplicitClass() { + osgiContext.registerService(ServiceInterface.class, new ServiceA()); + withMockedFrameworkUtil(() -> { + assertNoFailures(PseudoTestServiceMethodInjection.class, "annotatedParameterWithImpliedClass"); + }); + } + + @Test + void injectServiceAsAnnotatedMethodParameterWithExplicitClassMultiple() { + osgiContext.registerService(ServiceInterface.class, new ServiceA(), "service.ranking", 1); + osgiContext.registerService(ServiceInterface.class, new ServiceC(), "service.ranking", 3); + osgiContext.registerService(ServiceInterface.class, new ServiceB(), "service.ranking", 2); + withMockedFrameworkUtil(() -> { + assertNoFailures(PseudoTestServiceMethodInjection.class, "annotatedParameterWithExplicitClassMultiple"); + }); + } + + @Test + void injectServiceAsAnnotatedMethodParameterWithImplicitClassMultiple() { + osgiContext.registerService(ServiceInterface.class, new ServiceA(), "service.ranking", 1); + osgiContext.registerService(ServiceInterface.class, new ServiceC(), "service.ranking", 3); + osgiContext.registerService(ServiceInterface.class, new ServiceB(), "service.ranking", 2); + withMockedFrameworkUtil(() -> { + assertNoFailures(PseudoTestServiceMethodInjection.class, "annotatedParameterWithImpliedClassMultiple"); + }); + } + + + @Test + void injectServiceAsAnnotatedMethodParameterWithImplicitClassEmptyMultiple() { + withMockedFrameworkUtil(() -> { + assertNoFailures(PseudoTestServiceMethodInjection.class, "annotatedParameterWithImpliedClassEmptyMultiple"); + }); + } + + @Test + void injectServiceAsAnnotatedMethodParameterWithIncorrectExplicitClassMultiple() { + osgiContext.registerService(ServiceInterface.class, new ServiceA(), "service.ranking", 1); + osgiContext.registerService(ServiceInterface.class, new ServiceC(), "service.ranking", 3); + osgiContext.registerService(ServiceInterface.class, new ServiceB(), "service.ranking", 2); + withMockedFrameworkUtil(() -> { + final TestExecutionSummary summary = executeAndSummarize(PseudoTestServiceMethodInjection.class, "annotatedParameterWithIncorrectExplicitClassMultiple"); + assertEquals(1, summary.getTestsFailedCount(), "expected test failure count"); + final Throwable exception = summary.getFailures().get(0).getException(); + assertThat(exception, instanceOf(ParameterResolutionException.class)); + assertThat(exception.getMessage(), equalTo("Mismatched types in annotation and parameter. " + + "Annotation type is \"ServiceB\", parameter type is \"ServiceInterface\"")); + }); + } + + @Test + void injectServiceAsParameterOfAnnotatedMethod() { + osgiContext.registerService(ServiceInterface.class, new ServiceA()); + withMockedFrameworkUtil(() -> { + assertNoFailures(PseudoTestServiceMethodInjection.class, "annotatedMethod"); + }); + } + + @OSGi + static class PseudoTestServiceMethodInjection { + @Test + void annotatedParameterWithExplicitClass(@Service(ServiceInterface.class) ServiceInterface serviceA) { + assertThat(serviceA, instanceOf(ServiceA.class)); + } + + @Test + void annotatedParameterWithImpliedClass(@Service ServiceInterface serviceA) { + assertThat(serviceA, instanceOf(ServiceA.class)); + } + + @Test + void annotatedParameterWithExplicitClassMultiple(@Service(ServiceInterface.class) List<ServiceInterface> services) { + assertThat(services, instanceOf(List.class)); + assertThat(services, contains(asList(instanceOf(ServiceA.class), instanceOf(ServiceB.class), instanceOf(ServiceC.class)))); + } + + @Test + void annotatedParameterWithImpliedClassMultiple(@Service Collection<ServiceInterface> services) { + assertThat(services, instanceOf(Collection.class)); + assertThat(services, contains(asList(instanceOf(ServiceA.class), instanceOf(ServiceB.class), instanceOf(ServiceC.class)))); + } + + @Test + void annotatedParameterWithImpliedClassEmptyMultiple(@Service List<ServiceInterface> services) { + assertThat(services, instanceOf(List.class)); + assertThat(services, empty()); + } + + @Test + void annotatedParameterWithIncorrectExplicitClassMultiple(@Service(ServiceB.class) List<ServiceInterface> services) { + assertThat(services, instanceOf(List.class)); + assertThat(services, contains(instanceOf(ServiceA.class))); + } + + @Test + @Service(ServiceInterface.class) + void annotatedMethod(ServiceInterface serviceA) { + assertThat(serviceA, instanceOf(ServiceA.class)); + } + } + + private void withMockedFrameworkUtil(Runnable callback) { + try (final MockedStatic<FrameworkUtil> frameworkUtilMock = Mockito.mockStatic(FrameworkUtil.class)) { + frameworkUtilMock + .when(() -> FrameworkUtil.getBundle(Mockito.any())) + .then(invocation -> osgiContext.bundleContext().getBundle()); + callback.run(); + } + } + + @NotNull + private static Stream<Arguments> allTestMethods(Class<?> cls) { + return findAnnotatedMethods(cls, Testable.class, HierarchyTraversalMode.BOTTOM_UP).stream() + .map(toArguments(cls)); + } + + @NotNull + private static Function<Class<?>, Stream<Arguments>> namedMethods(String... testMethodNames) { + return cls -> findAnnotatedMethods(cls, Testable.class, HierarchyTraversalMode.BOTTOM_UP).stream() + .filter(method -> asList(testMethodNames).contains(method.getName())) + .map(toArguments(cls)); + } + + @NotNull + private static Function<Method, Arguments> toArguments(Class<?> cls) { + return method -> Arguments.of(cls.getSimpleName(), cls, method.getName()); + } + + private static TestExecutionSummary executeAndSummarize(@NotNull Class<?> testClass, @Nullable String testMethodName) { + final SummaryGeneratingListener listener = new SummaryGeneratingListener(); + JUnitPlatformHelper.executeTest(JUPITER_TEST_ENGINE, testClass, testMethodName, listener); + return listener.getSummary(); + } + + private static void assertNoFailures(@NotNull Class<?> testClass, @Nullable String testMethodName) { + final TestExecutionSummary summary = executeAndSummarize(testClass, testMethodName); + assertThat("number of tests found", (int) summary.getTestsFoundCount(), greaterThan(0)); + final List<TestExecutionSummary.Failure> failures = summary.getFailures(); + switch (failures.size()) { + case 0: + break; + case 1: + fail(failures.get(0).getException()); + default: + throw new MultipleFailuresError(null, failures.stream().map(TestExecutionSummary.Failure::getException).collect(Collectors.toList())); + } + } + + private void assertTestConstructionFailsDueToMissingService(Class<?> testClass, String testMethodName) { + final TestExecutionSummary summary = executeAndSummarize(testClass, testMethodName); + final List<TestExecutionSummary.Failure> failures = summary.getFailures(); + assertEquals(1, failures.size(), "number of test failures"); + final TestExecutionSummary.Failure failure = failures.get(0); + final Throwable exception = failure.getException(); + assertThat(exception, Matchers.instanceOf(ParameterResolutionException.class)); + assertThat(exception.getMessage(), anyOf( + allOf(containsString("No ParameterResolver registered for parameter "), containsString(" in constructor ")), + // allOf(containsString("Failed to resolve parameter "), containsString(" in constructor ")), + allOf(containsString("No service of type "), containsString(" available")) + )); + } + + @OSGi + static class PseudoTestBundleContextInjection extends Injection<BundleContext> { + public PseudoTestBundleContextInjection(BundleContext object) { + super(object); + } + } + + @OSGi + static class PseudoTestBundleInjection extends Injection<Bundle> { + public PseudoTestBundleInjection(Bundle object) { + super(object); + } + } + + @OSGi + static class PseudoTestServiceInjectionNotAnnotated extends Injection<ServiceInterface> { + public PseudoTestServiceInjectionNotAnnotated(ServiceInterface object) { + super(object); + } + + @Override + void injectedMethodParameter(ServiceInterface objectFromMethodInjection) { + super.injectedMethodParameter(objectFromMethodInjection); + } + } + + @OSGi + @Service(ServiceInterface.class) + static class PseudoTestServiceInjectionGloballyAnnotated extends Injection<ServiceInterface> { + public PseudoTestServiceInjectionGloballyAnnotated(ServiceInterface object) { + super(object); + } + } + + static class PseudoTestInheritedServiceInjectionGloballyAnnotated extends PseudoTestServiceInjectionGloballyAnnotated { + public PseudoTestInheritedServiceInjectionGloballyAnnotated(ServiceInterface object) { + super(object); + } + } + + @OSGi + @Service(value = ServiceInterface.class, filter = "(foo=quz)") + static class PseudoTestServiceInjectionGloballyAnnotatedWithFilter extends Injection<ServiceInterface> { + public PseudoTestServiceInjectionGloballyAnnotatedWithFilter(ServiceInterface object) { + super(object); + } + } + + static abstract class Injection<T> { + + T objectFromConstructor; + + private final String typeName; + + public Injection(T object) { + this.objectFromConstructor = object; + final ParameterizedType parameterizedType = parameterizedTypeForBaseClass(Injection.class, getClass()); + this.typeName = ((Class<?>) parameterizedType.getActualTypeArguments()[0]).getSimpleName(); + } + + @Test + final void injectedConstructorParameter() { + assertNotNull(objectFromConstructor, typeName + " constructor parameter"); + } + + @Test + void injectedMethodParameter(T objectFromMethodInjection) { + assertNotNull(objectFromMethodInjection, typeName + " method parameter"); + assertSame(objectFromConstructor, objectFromMethodInjection, + typeName + " same parameter should be injected into method and constructor"); + } + } + + interface ServiceInterface { + } + + static class ServiceA implements ServiceInterface { + } + + static class ServiceB implements ServiceInterface { + } + + static class ServiceC implements ServiceInterface { + } +}