You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by js...@apache.org on 2021/06/23 14:55:31 UTC

[sling-org-apache-sling-junit-core] 01/01: @Service annotation for Jupiter should explicitly allow for optional and mandatory service injection

This is an automated email from the ASF dual-hosted git repository.

jsedding pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-junit-core.git

commit 05eeeb42319eace3d8d7d111a9b60ef2b467b2e5
Author: Julian Sedding <js...@apache.org>
AuthorDate: Wed Jun 23 16:37:31 2021 +0200

    @Service annotation for Jupiter should explicitly allow for optional and mandatory service injection
    
    - added @Service#cardinality() attribute allowing AUTO, OPTIONAL
      and MANDATORY injection
    - refactored and enhanced tests
---
 .../osgi/BundleContextParameterResolver.java       |  10 +-
 .../jupiter/osgi/BundleParameterResolver.java      |   8 +-
 .../apache/sling/junit/jupiter/osgi/Service.java   |  23 +-
 .../junit/jupiter/osgi/ServiceCardinality.java     |  58 ++++
 .../jupiter/osgi/ServiceParameterResolver.java     | 183 ++++++-----
 .../impl/AbstractTypeBasedParameterResolver.java   |   4 +-
 .../osgi/impl/TypeBasedParameterResolver.java      |   5 +-
 .../sling/junit/jupiter/osgi/package-info.java     |   2 +-
 .../junit/jupiter/osgi/OSGiAnnotationTest.java     | 357 +++++++++++++++------
 .../junit/jupiter/osgi/utils/MemberMatcher.java    |  75 +++++
 .../sling/junit/jupiter/osgi/utils/MetaTest.java   |  89 +++++
 11 files changed, 633 insertions(+), 181 deletions(-)

diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/BundleContextParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/BundleContextParameterResolver.java
index 13b5f6d..f6e6914 100644
--- a/src/main/java/org/apache/sling/junit/jupiter/osgi/BundleContextParameterResolver.java
+++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/BundleContextParameterResolver.java
@@ -19,16 +19,22 @@
 package org.apache.sling.junit.jupiter.osgi;
 
 import org.apache.sling.junit.jupiter.osgi.impl.TypeBasedParameterResolver;
+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.ParameterResolutionException;
+import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.FrameworkUtil;
 
 import java.lang.reflect.Type;
+import java.util.Optional;
 
 class BundleContextParameterResolver extends TypeBasedParameterResolver<BundleContext> {
     @Override
-    protected BundleContext resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType) {
-        return FrameworkUtil.getBundle(extensionContext.getRequiredTestClass()).getBundleContext();
+    protected BundleContext resolveParameter(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull Type resolvedParameterType) {
+        return Optional.ofNullable(FrameworkUtil.getBundle(extensionContext.getRequiredTestClass()))
+                .map(Bundle::getBundleContext)
+                .orElseThrow(() -> new ParameterResolutionException("@OSGi and @Service annotations can only be used with tests running in an OSGi environment"));
     }
 }
diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/BundleParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/BundleParameterResolver.java
index cde68f3..cd065d4 100644
--- a/src/main/java/org/apache/sling/junit/jupiter/osgi/BundleParameterResolver.java
+++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/BundleParameterResolver.java
@@ -19,16 +19,20 @@
 package org.apache.sling.junit.jupiter.osgi;
 
 import org.apache.sling.junit.jupiter.osgi.impl.TypeBasedParameterResolver;
+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.ParameterResolutionException;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.FrameworkUtil;
 
 import java.lang.reflect.Type;
+import java.util.Optional;
 
 class BundleParameterResolver extends TypeBasedParameterResolver<Bundle> {
     @Override
-    protected Bundle resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType) {
-        return FrameworkUtil.getBundle(extensionContext.getRequiredTestClass());
+    protected Bundle resolveParameter(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull Type resolvedParameterType) {
+        return Optional.ofNullable(FrameworkUtil.getBundle(extensionContext.getRequiredTestClass()))
+                .orElseThrow(() -> new ParameterResolutionException("@OSGi and @Service annotations can only be used with tests running in an OSGi environment"));
     }
 }
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
index 3384a37..9705197 100644
--- a/src/main/java/org/apache/sling/junit/jupiter/osgi/Service.java
+++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/Service.java
@@ -18,6 +18,8 @@
  */
 package org.apache.sling.junit.jupiter.osgi;
 
+import org.osgi.annotation.versioning.ConsumerType;
+import org.osgi.annotation.versioning.ProviderType;
 import org.osgi.framework.Filter;
 
 import java.lang.annotation.ElementType;
@@ -34,9 +36,13 @@ import java.lang.annotation.Target;
  * 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.
+ * Supported parameter types are the service type itself for unary references (1..1),
+ * a {@link java.util.Collection} or {@link java.util.List} of the service type for optional
+ * and multiple references (0..n).
+ * <br>
+ * Additionally it is possible to refine the cardinality by specifying the {@code cardinality}
+ * attribute in order to require or not the presence of at least one service instance, thus
+ * achieving cardinalities (0..1) and (1..n).
  * <br>
  * When used on a test class, the specified services are made available for injection as parameters
  * to all of the test's methods.
@@ -47,9 +53,11 @@ import java.lang.annotation.Target;
  * 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.
+ *
+ * @see ServiceCardinality
  */
 @Retention(RetentionPolicy.RUNTIME)
-@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
+@Target({ElementType.TYPE, ElementType.CONSTRUCTOR, ElementType.METHOD, ElementType.PARAMETER})
 @Repeatable(Services.class)
 @Inherited
 public @interface Service {
@@ -66,5 +74,12 @@ public @interface Service {
      * An optional filter expression conforming to the LDAP filter syntax used in OSGi {@link Filter}s.
      */
     String filter() default "";
+
+    /**
+     * Whether or not injection fails in the absence of a suitable service.
+     *
+     * @see ServiceCardinality
+     */
+    ServiceCardinality cardinality() default ServiceCardinality.AUTO;
 }
 
diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/ServiceCardinality.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/ServiceCardinality.java
new file mode 100644
index 0000000..a15a479
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/ServiceCardinality.java
@@ -0,0 +1,58 @@
+/*
+ * 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;
+
+/**
+ * The cardinality of a service being injected is controlled via the type of the injected
+ * parameter and additionally via the {@link @Service} annotation's {@code cardinality}
+ * attribute.
+ * <br>
+ * The cardinality can be either {@code OPTIONAL} or {@code MANDATORY}. {@code OPTIONAL}
+ * does not require the presence of a service, whereas {@code MANDATORY} requires at
+ * least one service to be present, otherwise an exception is thrown.
+ * <br>
+ * The other aspect of cardinality, namely whether a single service or multiple services
+ * should be injected, is controlled via the type of the annotated field. For single service
+ * injection, the field's type is expected to be the type of the injected service. For multiple
+ * service injection, the field's type is expected to be a {@link java.util.Collection} or
+ * {@link java.util.List} with the type of the injected service as its generic type-argument.
+ * <br>
+ * Any further constraints, i.e. checking for an exact number or a range of services must be
+ * done via assertions.
+ *
+ * @see Service#cardinality()
+ */
+public enum ServiceCardinality {
+    /**
+     * For unary service injection, {@code AUTO} defaults to {@code MANDATORY}.
+     * Whereas for multiple service injection, {@code AUTO} defaults to {@code OPTIONAL}.
+     */
+    AUTO,
+
+    /**
+     * If no service is present, {@code null} is injected for unary service injection, and an empty
+     * {@code List} is injected for multiple service injection.
+     */
+    OPTIONAL,
+
+    /**
+     * At least one service must be present, otherwise a {@code ParameterResolutionException} is thrown.
+     */
+    MANDATORY
+}
diff --git a/src/main/java/org/apache/sling/junit/jupiter/osgi/ServiceParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/ServiceParameterResolver.java
index f2addda..f7c0ab8 100644
--- a/src/main/java/org/apache/sling/junit/jupiter/osgi/ServiceParameterResolver.java
+++ b/src/main/java/org/apache/sling/junit/jupiter/osgi/ServiceParameterResolver.java
@@ -45,35 +45,29 @@ 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;
-
 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))
+    protected boolean supportsParameter(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull Type resolvedParameterType) {
+        final Optional<Service> service = computeServiceType(resolvedParameterType)
+                .flatMap(serviceType -> findServiceAnnotation(parameterContext, extensionContext, serviceType));
+        return service
                 .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));
-                })
+    protected Object resolveParameter(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull Type resolvedParameterType) {
+        final ServiceHolder.Key key = computeServiceType(resolvedParameterType)
+                .flatMap(serviceType -> findServiceAnnotation(parameterContext, extensionContext, serviceType)
+                        .map(ann -> toKey(serviceType, ann)))
                 .orElseThrow(() -> new ParameterResolutionException("Cannot handle type " + resolvedParameterType));
+
+        final ServiceHolder serviceHolder = extensionContext.getStore(NAMESPACE).getOrComputeIfAbsent(key, serviceHolderFactory(extensionContext), ServiceHolder.class);
+        return isMultiple(resolvedParameterType) ? serviceHolder.getServices() : serviceHolder.getService();
     }
 
     private static ServiceHolder.Key toKey(Class<?> serviceType, Service serviceAnnotation) {
@@ -81,11 +75,10 @@ class ServiceParameterResolver extends AbstractTypeBasedParameterResolver {
     }
 
     @NotNull
-    private static Optional<Class<?>> computeServiceType(Type resolvedParameterType) {
+    private static Optional<Class<?>> computeServiceType(@NotNull Type resolvedParameterType) {
         if (resolvedParameterType instanceof ParameterizedType) {
             final ParameterizedType parameterizedType = (ParameterizedType) resolvedParameterType;
-            final Class<?> clazz = getRawClass(parameterizedType);
-            if (Collection.class == clazz || List.class.isAssignableFrom(clazz)) {
+            if (isMultiple(parameterizedType)) {
                 final Type[] actualTypeArguments = parameterizedType.getActualTypeArguments();
                 if (actualTypeArguments.length == 1 && actualTypeArguments[0] instanceof Class<?>) {
                     return Optional.of((Class<?>) actualTypeArguments[0]);
@@ -106,63 +99,55 @@ class ServiceParameterResolver extends AbstractTypeBasedParameterResolver {
         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) {
+    private static Function<ServiceHolder.Key, ServiceHolder> serviceHolderFactory(ExtensionContext extensionContext) {
         return key -> new ServiceHolder(getBundleContext(extensionContext), key);
     }
 
-    @Nullable
-    private static BundleContext getBundleContext(ExtensionContext extensionContext) {
+    @NotNull
+    private static BundleContext getBundleContext(@NotNull ExtensionContext extensionContext) {
         return Optional.ofNullable(FrameworkUtil.getBundle(extensionContext.getRequiredTestClass()))
                 .map(Bundle::getBundleContext)
-                .orElse(null);
+                .orElseThrow(() -> new ParameterResolutionException("@OSGi and @Service annotations can only be used with tests running in an OSGi environment"));
     }
 
     @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)
+    private static Optional<Service> findServiceAnnotation(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull Class<?> requiredServiceType) {
+        return Stream.concat(
+                Stream.of(findMatchingServiceAnnotationOnParameter(parameterContext, requiredServiceType)),
+                Stream.of(parameterContext.getDeclaringExecutable(), extensionContext.getRequiredTestClass())
+                        .map(annotatedElement -> findMatchingServiceAnnotation(annotatedElement, requiredServiceType)))
+                .filter(Objects::nonNull)
                 .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 Service findMatchingServiceAnnotationOnParameter(@NotNull ParameterContext parameterContext, @NotNull Class<?> requiredServiceType) {
+        final List<Service> serviceAnnotations = parameterContext.findRepeatableAnnotations(Service.class);
+        switch (serviceAnnotations.size()) {
+            case 0:
+                return null;
+            case 1:
+                final Service serviceAnnotation = serviceAnnotations.get(0);
+                if (!serviceAnnotation.value().isAssignableFrom(requiredServiceType)) {
+                    throw new ParameterResolutionException("Mismatched types in annotation and parameter. " +
+                            "Annotation type is \"" + serviceAnnotation.value().getSimpleName() + "\", parameter type is \"" + requiredServiceType.getSimpleName() + "\"");
+                }
+                return serviceAnnotation;
+            default:
+                throw new ParameterResolutionException("Parameters must not be annotated with multiple @Service annotations: " + parameterContext.getDeclaringExecutable());
+        }
     }
 
-    private static Optional<Service> findMatchingServiceAnnotation(AnnotatedElement annotatedElement, Class<?> requiredServiceType) {
+    @Nullable
+    private static Service findMatchingServiceAnnotation(@Nullable AnnotatedElement annotatedElement, @NotNull Class<?> requiredServiceType) {
         return AnnotationSupport.findRepeatableAnnotations(annotatedElement, Service.class)
                 .stream()
                 .filter(serviceAnnotation -> Objects.equals(serviceAnnotation.value(), requiredServiceType))
-                .findFirst();
+                .findFirst()
+                .orElse(null);
     }
 
-    private boolean isMultiple(Type resolvedParameterType) {
+    private static boolean isMultiple(@NotNull Type resolvedParameterType) {
         if (resolvedParameterType instanceof ParameterizedType) {
             final Class<?> type = getRawClass((ParameterizedType) resolvedParameterType);
             return Collection.class == type || List.class.isAssignableFrom(type);
@@ -172,9 +157,12 @@ class ServiceParameterResolver extends AbstractTypeBasedParameterResolver {
 
     private static class ServiceHolder implements ExtensionContext.Store.CloseableResource {
 
+        private final Key key;
+
         private final ServiceTracker<?, ?> serviceTracker;
 
-        private ServiceHolder(BundleContext bundleContext, Key key) {
+        private ServiceHolder(@NotNull BundleContext bundleContext, @NotNull Key key) {
+            this.key = key;
             final Filter filter = createFilter(bundleContext, key.type(), key.filter());
             serviceTracker = new SortingServiceTracker<>(bundleContext, filter);
             serviceTracker.open();
@@ -185,19 +173,52 @@ class ServiceParameterResolver extends AbstractTypeBasedParameterResolver {
             serviceTracker.close();
         }
 
-        public Object getService() {
-            return serviceTracker.getService();
+        @Nullable
+        public Object getService() throws ParameterResolutionException{
+            final Object service = serviceTracker.getService();
+            return checkCardinality(service, false);
+        }
+
+        @NotNull
+        public List<Object> getServices() throws ParameterResolutionException {
+            @Nullable final Object[] services = serviceTracker.getServices();;
+            return Optional.ofNullable(checkCardinality(services, true))
+                    .map(Arrays::asList)
+                    .orElseGet(Collections::emptyList);
+        }
+
+        @Nullable
+        private <T> T checkCardinality(@Nullable T service, boolean isMultiple) throws ParameterResolutionException {
+            final ServiceCardinality effectiveCardinality = calculateEffectiveCardinality(isMultiple);
+            if (service == null && effectiveCardinality == ServiceCardinality.MANDATORY) {
+                throw createServiceNotFoundException(key.filter(), key.type());
+            }
+            return service;
+        }
+
+        @NotNull
+        private ServiceCardinality calculateEffectiveCardinality(boolean isMultiple) {
+            final ServiceCardinality cardinality = key.cardinality();
+            if (cardinality == ServiceCardinality.AUTO) {
+                return isMultiple ? ServiceCardinality.OPTIONAL : ServiceCardinality.MANDATORY;
+            }
+            return cardinality;
         }
 
-        public List<Object> getServices() {
-            final Object[] services = serviceTracker.getServices();
-            return services == null ? Collections.emptyList() : Arrays.asList(services);
+        @NotNull
+        private static ParameterResolutionException createServiceNotFoundException(@NotNull String ldapFilter, @NotNull Type resolvedParameterType) {
+            return Optional.of(ldapFilter)
+                    .map(String::trim)
+                    .filter(filter -> !filter.isEmpty())
+                    .map(filter -> new ParameterResolutionException("No service of type \"" + resolvedParameterType.getTypeName() + "\" with filter \"" + filter + "\" available"))
+                    .orElseGet(() -> new ParameterResolutionException("No service of type \"" + resolvedParameterType.getTypeName() + "\" available"));
         }
 
-        private static Filter createFilter(BundleContext bundleContext, Class<?> clazz, String ldapFilter) {
+        @NotNull
+        private static Filter createFilter(@NotNull BundleContext bundleContext, @NotNull Class<?> clazz, @NotNull String ldapFilter) {
             final String classFilter = String.format("(%s=%s)", Constants.OBJECTCLASS, clazz.getName());
             final String combinedFilter;
-            if (ldapFilter == null || ldapFilter.trim().isEmpty()) {
+            if (ldapFilter.trim().isEmpty()) {
                 combinedFilter = classFilter;
             } else {
                 combinedFilter = String.format("(&%s%s)", classFilter, ldapFilter);
@@ -205,16 +226,17 @@ class ServiceParameterResolver extends AbstractTypeBasedParameterResolver {
             try {
                 return bundleContext.createFilter(combinedFilter);
             } catch (InvalidSyntaxException e) {
-                throw new IllegalArgumentException("Invalid filter expression: \"" + ldapFilter + "\"", e);
+                throw new ParameterResolutionException("Invalid filter expression used in @Service annotation :\"" + ldapFilter + "\"", e);
             }
         }
 
         private static class SortingServiceTracker<T> extends ServiceTracker<T, T> {
-            public SortingServiceTracker(BundleContext bundleContext, Filter filter) {
+            public SortingServiceTracker(@NotNull BundleContext bundleContext, @NotNull Filter filter) {
                 super(bundleContext, filter, null);
             }
 
             @Override
+            @Nullable
             public ServiceReference<T>[] getServiceReferences() {
                 return Optional.ofNullable(super.getServiceReferences())
                         .map(serviceReferences -> {
@@ -231,11 +253,26 @@ class ServiceParameterResolver extends AbstractTypeBasedParameterResolver {
 
             private final Service serviceAnnotation;
 
-            public Key(Class<?> serviceType, Service serviceAnnotation) {
+            public Key(@NotNull Class<?> serviceType, @NotNull Service serviceAnnotation) {
                 this.serviceType = serviceType;
                 this.serviceAnnotation = serviceAnnotation;
             }
 
+            @NotNull
+            public Class<?> type() {
+                return serviceType;
+            }
+
+            @NotNull
+            public String filter() {
+                return serviceAnnotation.filter();
+            }
+
+            @NotNull
+            public ServiceCardinality cardinality() {
+                return serviceAnnotation.cardinality();
+            }
+
             @Override
             public boolean equals(Object o) {
                 if (!(o instanceof Key)) {
@@ -251,14 +288,6 @@ class ServiceParameterResolver extends AbstractTypeBasedParameterResolver {
             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/AbstractTypeBasedParameterResolver.java b/src/main/java/org/apache/sling/junit/jupiter/osgi/impl/AbstractTypeBasedParameterResolver.java
index 58697fa..58e10bd 100644
--- 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
@@ -37,9 +37,9 @@ import static org.apache.sling.junit.jupiter.osgi.impl.ReflectionHelper.determin
  */
 public abstract class AbstractTypeBasedParameterResolver implements ParameterResolver {
 
-    protected abstract boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType);
+    protected abstract boolean supportsParameter(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull Type resolvedParameterType);
 
-    protected abstract Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType);
+    protected abstract Object resolveParameter(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull Type resolvedParameterType);
 
     @Override
     public final boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) {
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
index 9bec7af..30aca67 100644
--- 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
@@ -18,6 +18,7 @@
  */
 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;
 
@@ -44,10 +45,10 @@ public abstract class TypeBasedParameterResolver<T> extends AbstractTypeBasedPar
     }
 
     @Override
-    protected boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType) {
+    protected boolean supportsParameter(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull Type resolvedParameterType) {
         return supportedType == resolvedParameterType;
     }
 
     @Override
-    protected abstract T resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, Type resolvedParameterType);
+    protected abstract T resolveParameter(@NotNull ParameterContext parameterContext, @NotNull ExtensionContext extensionContext, @NotNull 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
index 83d36bf..cf693ad 100644
--- 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
@@ -16,7 +16,7 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-@Version("1.0.0")
+@Version("1.1.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/jupiter/osgi/OSGiAnnotationTest.java b/src/test/java/org/apache/sling/junit/jupiter/osgi/OSGiAnnotationTest.java
index 03c637a..5fd8bd4 100644
--- a/src/test/java/org/apache/sling/junit/jupiter/osgi/OSGiAnnotationTest.java
+++ b/src/test/java/org/apache/sling/junit/jupiter/osgi/OSGiAnnotationTest.java
@@ -19,18 +19,17 @@
 package org.apache.sling.junit.jupiter.osgi;
 
 import org.apache.sling.junit.impl.servlet.junit5.JUnitPlatformHelper;
+import org.apache.sling.junit.jupiter.osgi.utils.MetaTest;
 import org.apache.sling.testing.mock.osgi.junit5.OsgiContext;
 import org.apache.sling.testing.mock.osgi.junit5.OsgiContextExtension;
-import org.hamcrest.Matchers;
+import org.hamcrest.Matcher;
 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;
@@ -44,6 +43,7 @@ import org.osgi.framework.FrameworkUtil;
 
 import java.lang.reflect.Method;
 import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
 import java.util.Collection;
 import java.util.List;
 import java.util.function.Function;
@@ -51,16 +51,23 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static java.util.Arrays.asList;
+import static org.apache.sling.junit.jupiter.osgi.ServiceCardinality.MANDATORY;
+import static org.apache.sling.junit.jupiter.osgi.ServiceCardinality.OPTIONAL;
 import static org.apache.sling.junit.jupiter.osgi.impl.ReflectionHelper.parameterizedTypeForBaseClass;
+import static org.apache.sling.junit.jupiter.osgi.utils.MemberMatcher.hasMember;
 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.endsWith;
 import static org.hamcrest.Matchers.equalTo;
 import static org.hamcrest.Matchers.greaterThan;
 import static org.hamcrest.Matchers.instanceOf;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.nullValue;
+import static org.hamcrest.Matchers.sameInstance;
+import static org.hamcrest.Matchers.startsWith;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
@@ -85,32 +92,19 @@ class OSGiAnnotationTest {
 
     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")
+    @MetaTest({BundleInjection.class, BundleContextInjection.class})
     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")
+    @MetaTest({ServiceInjectionGloballyAnnotated.class,
+            InheritedServiceInjectionGloballyAnnotated.class,
+            ServiceInjectionGloballyAnnotatedWithFilter.class,
+            MultipleServiceInjectionParameterAnnotated.class,
+            MultipleServiceInjectionMethodAnnotated.class,
+            MultipleServiceInjectionClassAnnotated.class})
     void injectServices(String name, Class<?> testClass, String testMethodName) {
         osgiContext.registerService(ServiceInterface.class, new ServiceA(), "foo", "quz");
         withMockedFrameworkUtil(() -> {
@@ -118,36 +112,47 @@ class OSGiAnnotationTest {
         });
     }
 
-    @SuppressWarnings("unused") // provides parameters
-    static Stream<Arguments> failConstructionDueToMissingServiceInjectionTests() {
-        return Stream.of(PseudoTestServiceInjectionNotAnnotated.class, PseudoTestServiceInjectionGloballyAnnotatedWithFilter.class)
-                .flatMap(namedMethods("injectedConstructorParameter"));
+    @MetaTest(value = ServiceInjectionNoServiceAnnotation.class, methods = "injectedConstructorParameter")
+    void failConstructionDueToMissingServiceAnnotation(String name, Class<?> testClass, String testMethodName) {
+        osgiContext.registerService(ServiceInterface.class, new ServiceA());
+        withMockedFrameworkUtil(() -> {
+            assertFailure(testClass, testMethodName, ParameterResolutionException.class,
+                    allOf(containsString("No ParameterResolver registered for parameter "), containsString(" in constructor ")));
+        });
     }
 
-    @ParameterizedTest(name = "{0}#{2}")
-    @MethodSource("failConstructionDueToMissingServiceInjectionTests")
-    void failConstructionDueToMissingServiceInjection(String name, Class<?> testClass, String testMethodName) {
+    @MetaTest(value = ServiceInjectionGloballyAnnotatedWithFilter.class, methods = "injectedConstructorParameter")
+    void failConstructionDueToMissingService(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);
+            assertFailure(testClass, testMethodName, ParameterResolutionException.class,
+                    allOf(containsString("No service of type "), containsString(" available")));
         });
     }
 
-    @SuppressWarnings("unused")
-    static Stream<Arguments> serviceInjectionVariantsTests() {
-        return Stream.of(PseudoTestServiceMethodInjection.class)
-                .flatMap(namedMethods(
-                        "annotatedParameterWithExplicitClass",
-                        "annotatedParameterWithImpliedClass",
-                        "annotatedParameterWithExplicitClassMultiple",
-                        "annotatedParameterWithImpliedClassMultiple",
-                        "annotatedMethod"));
+    @MetaTest(MultipleAnnotationsOnParameterFailure.class)
+    void failMultipleAnnotationsOnParameter(String name, Class<?> testClass, String testMethodName) {
+        withMockedFrameworkUtil(() -> {
+            assertFailure(testClass, testMethodName, ParameterResolutionException.class,
+                    allOf(
+                            startsWith("Parameters must not be annotated with multiple @Service annotations: "),
+                            containsString("MultipleAnnotationsOnParameterFailure"), // class name
+                            containsString("multipleAnnotationsFailure"), // method name
+                            containsString("MissingServiceInterface"))); // parameter type
+        });
     }
 
-    @ParameterizedTest(name = "{0}#{2}")
-    @MethodSource("serviceInjectionVariantsTests")
-    void serviceInjectionVariants(String name, Class<?> testClass, String testMethodName) {
+    @MetaTest(InvalidFilterExpressionFailure.class)
+    void failInvalidFilterExpression(String name, Class<?> testClass, String testMethodName) {
+        withMockedFrameworkUtil(() -> {
+            assertFailure(testClass, testMethodName, ParameterResolutionException.class,
+                    equalTo("Invalid filter expression used in @Service annotation :\"(abc = def\""));
+        });
+    }
+
+    @MetaTest(ServiceMethodInjection.class)
+    void allServiceMethodInjectionTests(String name, Class<?> testClass, String testMethodName) {
         osgiContext.registerService(ServiceInterface.class, new ServiceA(), "service.ranking", 3);
         osgiContext.registerService(ServiceInterface.class, new ServiceC(), "service.ranking", 1);
         osgiContext.registerService(ServiceInterface.class, new ServiceB(), "service.ranking", 2);
@@ -156,30 +161,149 @@ class OSGiAnnotationTest {
         });
     }
 
-    @Test
-    void injectServiceAsAnnotatedMethodParameterWithImplicitClassEmptyMultiple() {
+    @MetaTest(value = ServiceMethodInjection.class, methods = {
+            "annotatedParameterWithImpliedClassOptionalMultiple",
+            "annotatedParameterWithImpliedClassExplicitOptionalMultiple"
+    })
+    void injectServiceAsAnnotatedMethodParameterWithImplicitClassEmptyMultiple(String name, Class<?> testClass, String testMethodName) {
+        withMockedFrameworkUtil(() -> {
+            assertNoFailures(testClass, testMethodName);
+        });
+    }
+
+    @MetaTest(MismatchedServiceTypeOnAnnotatedParameter.class)
+    void failOnMismatchedServiceType(String name, Class<?> testClass, String testMethodName) {
+        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, "annotatedParameterWithImpliedClassEmptyMultiple");
+            assertFailure(testClass, testMethodName, ParameterResolutionException.class,
+                    equalTo("Mismatched types in annotation and parameter. " +
+                            "Annotation type is \"ServiceB\", parameter type is \"ServiceInterface\""));
         });
     }
 
-    @Test
-    void injectServiceAsAnnotatedMethodParameterWithIncorrectExplicitClassMultiple() {
+    @MetaTest(MissingMandatoryServiceInjectionOnAnnotatedParameter.class)
+    void failOnMissingMandatoryService(String name, Class<?> testClass, String testMethodName) {
         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\""));
+            assertFailure(testClass, testMethodName, ParameterResolutionException.class,
+                    equalTo("No service of type \"org.apache.sling.junit." +
+                            "jupiter.osgi.OSGiAnnotationTest$MissingServiceInterface\" available"));
         });
     }
 
+    @MetaTest(MissingFilteredMandatoryServiceInjectionOnAnnotatedParameter.class)
+    void failOnMissingFilteredService(String name, Class<?> testClass, String testMethodName) {
+        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(() -> {
+            assertFailure(testClass, testMethodName, ParameterResolutionException.class,
+                    equalTo("No service of type \"org.apache.sling.junit." +
+                            "jupiter.osgi.OSGiAnnotationTest$ServiceInterface\" " +
+                            "with filter \"(service.ranking=100)\" available"));
+        });
+    }
+
+
+    @MetaTest({BundleInjection.class, BundleContextInjection.class, ServiceInjectionGloballyAnnotated.class})
+    void failOutsideOSGiEnvironment(String name, Class<?> testClass, String testMethodName) {
+        assertFailure(testClass, testMethodName, ParameterResolutionException.class,
+                endsWith("@OSGi and @Service annotations can only be used with tests running in an OSGi environment"));
+    }
+
+    private void assertFailure(Class<?> testClass, String testMethodName, Class<? extends Throwable> exceptionClass, Matcher<String> exceptionMessageMatcher) {
+        final TestExecutionSummary summary = executeAndSummarize(testClass, testMethodName);
+        final List<TestExecutionSummary.Failure> failures = summary.getFailures().stream().filter(failure -> failure.getTestIdentifier().isTest()).collect(Collectors.toList());
+        assertThat(failures, contains(
+                hasMember("getException()", TestExecutionSummary.Failure::getException,
+                    allOf(instanceOf(exceptionClass), hasMember("getMessage()", Throwable::getMessage, exceptionMessageMatcher)))));
+    }
+
     @OSGi
-    static class PseudoTestServiceMethodInjection {
+    static class MismatchedServiceTypeOnAnnotatedParameter {
+
+        @Test
+        void unary(@Service(ServiceB.class) ServiceInterface service) {
+            failMismatchedServiceType();
+        }
+
+        @Test
+        void multiple(@Service(ServiceB.class) List<ServiceInterface> services) {
+            failMismatchedServiceType();
+        }
+
+        private static void failMismatchedServiceType() {
+            fail("Method not be called due to mismatching service types");
+        }
+    }
+
+    @OSGi
+    @Service(value = ServiceInterface.class, cardinality = MANDATORY, filter = "(service.ranking=100)")
+    static class MissingFilteredMandatoryServiceInjectionOnAnnotatedParameter {
+
+        @Test
+        void missingUnaryFilteredService(ServiceInterface service) {
+            failMissing();
+        }
+
+        @Test
+        void missingMultipleFilteredService(List<ServiceInterface> services) {
+            failMissing();
+        }
+
+        private static void failMissing() {
+            fail("Method must not be called due to missing service with matching filter");
+        }
+
+    }
+
+    @OSGi
+    static class MissingMandatoryServiceInjectionOnAnnotatedParameter {
+
+        @Test
+        void missingImplictlyMandatoryUnaryService(@Service MissingServiceInterface service) {
+            failMandatory();
+        }
+
+        @Test
+        void missingExplicitlyMandatoryUnaryService(@Service(cardinality = MANDATORY) MissingServiceInterface service) {
+            failMandatory();
+        }
+
+        @Test
+        void missingMandatoryMultipleService(@Service(cardinality = MANDATORY) List<MissingServiceInterface> services) {
+            failMandatory();
+        }
+
+        private static void failMandatory() {
+            fail("Method must not be called due to missing mandatory service");
+        }
+    }
+
+    @OSGi
+    static class ServiceMethodInjection {
+
+        @Test
+        void annotatedParameterWithMissingOptionalService(@Service(cardinality = OPTIONAL) MissingServiceInterface service) {
+            assertThat(service, nullValue());
+        }
+
+        @Test
+        void annotatedParameterWithImpliedClassExplicitOptionalMultiple(@Service(cardinality = OPTIONAL) List<MissingServiceInterface> services) {
+            assertThat(services, instanceOf(List.class));
+            assertThat(services, empty());
+        }
+
+        @Test
+        void annotatedParameterWithImpliedClassOptionalMultiple(@Service List<MissingServiceInterface> services) {
+            assertThat(services, instanceOf(List.class));
+            assertThat(services, empty());
+        }
+
         @Test
         void annotatedParameterWithExplicitClass(@Service(ServiceInterface.class) ServiceInterface serviceA) {
             assertThat(serviceA, instanceOf(ServiceA.class));
@@ -203,15 +327,14 @@ class OSGiAnnotationTest {
         }
 
         @Test
-        void annotatedParameterWithImpliedClassEmptyMultiple(@Service List<ServiceInterface> services) {
+        void annotatedParameterWithImpliedClassExplicitMandatoryMultiple(@Service(cardinality = MANDATORY) List<ServiceInterface> services) {
             assertThat(services, instanceOf(List.class));
-            assertThat(services, empty());
+            assertThat(services, contains(asList(instanceOf(ServiceA.class), instanceOf(ServiceB.class), instanceOf(ServiceC.class))));
         }
 
         @Test
-        void annotatedParameterWithIncorrectExplicitClassMultiple(@Service(ServiceB.class) List<ServiceInterface> services) {
-            assertThat(services, instanceOf(List.class));
-            assertThat(services, contains(instanceOf(ServiceA.class)));
+        void annotatedParameterWithExistingOptionalService(@Service(cardinality = OPTIONAL) ServiceInterface service) {
+            assertThat(service, instanceOf(ServiceA.class));
         }
 
         @Test
@@ -269,78 +392,127 @@ class OSGiAnnotationTest {
         }
     }
 
-    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 BundleContextInjection extends Injection<BundleContext> {
+        public BundleContextInjection(BundleContext object) {
+            super(object);
+        }
+    }
+
+    @OSGi
+    static class BundleInjection extends Injection<Bundle> {
+        public BundleInjection(Bundle object) {
+            super(object);
+        }
+    }
+
+    @OSGi
+    static class ServiceInjectionNoServiceAnnotation extends Injection<ServiceInterface> {
+        public ServiceInjectionNoServiceAnnotation(ServiceInterface object) {
+            super(object);
+        }
     }
 
     @OSGi
-    static class PseudoTestBundleContextInjection extends Injection<BundleContext> {
-        public PseudoTestBundleContextInjection(BundleContext object) {
+    @Service(ServiceInterface.class)
+    static class ServiceInjectionGloballyAnnotated extends Injection<ServiceInterface> {
+        public ServiceInjectionGloballyAnnotated(ServiceInterface object) {
             super(object);
         }
     }
 
     @OSGi
-    static class PseudoTestBundleInjection extends Injection<Bundle> {
-        public PseudoTestBundleInjection(Bundle object) {
+    @Service(value = ServiceInterface.class, filter = "(foo=quz)")
+    static class ServiceInjectionGloballyAnnotatedWithFilter extends Injection<ServiceInterface> {
+        public ServiceInjectionGloballyAnnotatedWithFilter(ServiceInterface object) {
+            super(object);
+        }
+    }
+
+    static class InheritedServiceInjectionGloballyAnnotated extends ServiceInjectionGloballyAnnotated {
+        public InheritedServiceInjectionGloballyAnnotated(ServiceInterface object) {
             super(object);
         }
     }
 
     @OSGi
-    static class PseudoTestServiceInjectionNotAnnotated extends Injection<ServiceInterface> {
-        public PseudoTestServiceInjectionNotAnnotated(ServiceInterface object) {
+    static class MultipleServiceInjectionParameterAnnotated extends AbstractMultipleServiceInjection {
+        public MultipleServiceInjectionParameterAnnotated(@Service List<ServiceInterface> object) {
             super(object);
         }
 
         @Override
-        void injectedMethodParameter(ServiceInterface objectFromMethodInjection) {
+        void injectedMethodParameter(@Service List<ServiceInterface> objectFromMethodInjection) {
             super.injectedMethodParameter(objectFromMethodInjection);
         }
     }
 
     @OSGi
-    @Service(ServiceInterface.class)
-    static class PseudoTestServiceInjectionGloballyAnnotated extends Injection<ServiceInterface> {
-        public PseudoTestServiceInjectionGloballyAnnotated(ServiceInterface object) {
+    static class MultipleServiceInjectionMethodAnnotated extends AbstractMultipleServiceInjection {
+
+        @Service(ServiceInterface.class)
+        public MultipleServiceInjectionMethodAnnotated(List<ServiceInterface> object) {
             super(object);
         }
+
+        @Test @Override
+        @Service(ServiceInterface.class)
+        void injectedMethodParameter(List<ServiceInterface> objectFromMethodInjection) {
+            super.injectedMethodParameter(objectFromMethodInjection);
+        }
     }
 
-    static class PseudoTestInheritedServiceInjectionGloballyAnnotated extends PseudoTestServiceInjectionGloballyAnnotated {
-        public PseudoTestInheritedServiceInjectionGloballyAnnotated(ServiceInterface object) {
+    @OSGi
+    @Service(ServiceInterface.class)
+    static class MultipleServiceInjectionClassAnnotated extends AbstractMultipleServiceInjection {
+        public MultipleServiceInjectionClassAnnotated(List<ServiceInterface> object) {
             super(object);
         }
     }
 
     @OSGi
-    @Service(value = ServiceInterface.class, filter = "(foo=quz)")
-    static class PseudoTestServiceInjectionGloballyAnnotatedWithFilter extends Injection<ServiceInterface> {
-        public PseudoTestServiceInjectionGloballyAnnotatedWithFilter(ServiceInterface object) {
+    static class MultipleAnnotationsOnParameterFailure {
+        @Test
+        void multipleAnnotationsFailure(@Service @Service(MissingServiceInterface.class) MissingServiceInterface service) {
+            fail("Method must not be called due to duplicate @Service annotation on parameter");
+        }
+    }
+
+    @OSGi
+    static class InvalidFilterExpressionFailure {
+        @Test
+        void invalidFilterExpressionFailure(@Service(filter = "(abc = def") MissingServiceInterface service) {
+            fail("Method must not be called due to duplicate @Service annotation on parameter");
+        }
+    }
+
+    static abstract class AbstractMultipleServiceInjection extends Injection<List<ServiceInterface>> {
+
+        public AbstractMultipleServiceInjection(List<ServiceInterface> object) {
             super(object);
         }
+
+        @Test @Override
+        void injectedMethodParameter(List<ServiceInterface> objectFromMethodInjection) {
+            assertNotNull(objectFromMethodInjection, typeName + " method parameter");
+            assertEquals(objectFromConstructor, objectFromMethodInjection);
+            assertThat("number of services", objectFromMethodInjection.size(), is(1));
+            assertThat( "same service instance should be contained in the Lists injected into method and constructor",
+                    objectFromConstructor, contains(sameInstance(objectFromMethodInjection.get(0))));
+        }
     }
 
     static abstract class Injection<T> {
 
-        T objectFromConstructor;
+        protected T objectFromConstructor;
 
-        private final String typeName;
+        protected final String typeName;
 
         public Injection(T object) {
             this.objectFromConstructor = object;
             final ParameterizedType parameterizedType = parameterizedTypeForBaseClass(Injection.class, getClass());
-            this.typeName = ((Class<?>) parameterizedType.getActualTypeArguments()[0]).getSimpleName();
+            final Type actualTypeArgument = parameterizedType.getActualTypeArguments()[0];
+            this.typeName = actualTypeArgument.getTypeName();
         }
 
         @Test
@@ -367,4 +539,7 @@ class OSGiAnnotationTest {
 
     static class ServiceC implements ServiceInterface {
     }
+
+    interface MissingServiceInterface {
+    }
 }
diff --git a/src/test/java/org/apache/sling/junit/jupiter/osgi/utils/MemberMatcher.java b/src/test/java/org/apache/sling/junit/jupiter/osgi/utils/MemberMatcher.java
new file mode 100644
index 0000000..65d003c
--- /dev/null
+++ b/src/test/java/org/apache/sling/junit/jupiter/osgi/utils/MemberMatcher.java
@@ -0,0 +1,75 @@
+/*
+ * 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.utils;
+
+import org.hamcrest.Description;
+import org.hamcrest.Factory;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeDiagnosingMatcher;
+
+import java.util.function.Function;
+
+import static org.hamcrest.Condition.matched;
+
+public class MemberMatcher<S, T> extends TypeSafeDiagnosingMatcher<S> {
+
+    private final String memberName;
+
+    private final Function<S, T> memberAccessor;
+
+    private final Matcher<T> valueMatcher;
+
+    private MemberMatcher(String memberName, Function<S,T> memberAccessor, Matcher<T> valueMatcher) {
+        this.memberName = memberName;
+        this.memberAccessor = memberAccessor;
+        this.valueMatcher = valueMatcher;
+    }
+
+    @Override
+    public boolean matchesSafely(S object, Description mismatch) {
+        T value = memberAccessor.apply(object);
+        return matched(value, mismatch)
+                .matching(valueMatcher);
+    }
+
+    @Override
+    public void describeTo(Description description) {
+        description.appendText("(").appendText(memberName).appendText(", ")
+                .appendDescriptionOf(valueMatcher).appendText(")");
+    }
+
+
+    /**
+     * Creates a matcher that matches when the examined object has a JavaBean property
+     * with the specified name whose value satisfies the specified matcher.
+     * <p/>
+     * For example:
+     * <pre>assertThat(myBean, hasProperty("foo", equalTo("bar"))</pre>
+     *
+     * @param memberAccessor
+     *     the name of the JavaBean property that examined beans should possess
+     * @param valueMatcher
+     *     a matcher for the value of the specified property of the examined bean
+     */
+    @Factory
+    public static <S, T> Matcher<S> hasMember(String name, Function<S, T> memberAccessor, Matcher<T> valueMatcher) {
+        return new MemberMatcher<>(name, memberAccessor, valueMatcher);
+    }
+}
+
diff --git a/src/test/java/org/apache/sling/junit/jupiter/osgi/utils/MetaTest.java b/src/test/java/org/apache/sling/junit/jupiter/osgi/utils/MetaTest.java
new file mode 100644
index 0000000..08ea623
--- /dev/null
+++ b/src/test/java/org/apache/sling/junit/jupiter/osgi/utils/MetaTest.java
@@ -0,0 +1,89 @@
+/*
+ * 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.utils;
+
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.extension.ExtensionContext;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.ArgumentsProvider;
+import org.junit.jupiter.params.provider.ArgumentsSource;
+import org.junit.jupiter.params.support.AnnotationConsumer;
+import org.junit.platform.commons.annotation.Testable;
+import org.junit.platform.commons.support.HierarchyTraversalMode;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.function.Function;
+import java.util.stream.Stream;
+
+import static java.util.Arrays.asList;
+import static org.junit.platform.commons.support.AnnotationSupport.findAnnotatedMethods;
+
+@Target({ElementType.ANNOTATION_TYPE, ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@ArgumentsSource(MetaTestSourceProvider.class)
+@ParameterizedTest(name = "{0}#{2}")
+public @interface MetaTest {
+    /**
+     * Meta test classes, i.e. classes with methods directly or indirectly
+     * annotated as {@code @Testable}.
+     */
+    Class<?>[] value();
+
+    /**
+     * List of test method names that should be called on the test class(es).
+     * By default all {@code @Testable} methods are called.
+     */
+    String[] methods() default {};
+}
+
+class MetaTestSourceProvider implements ArgumentsProvider, AnnotationConsumer<MetaTest> {
+
+    private Class<?>[] testClasses;
+
+    private List<String> allowedMethodNames;
+
+    @Override
+    public void accept(MetaTest metaTest) {
+        testClasses = metaTest.value();
+        allowedMethodNames = asList(metaTest.methods());
+    }
+
+    @Override
+    public Stream<? extends Arguments> provideArguments(ExtensionContext extensionContext) {
+        return Stream.of(testClasses).flatMap(this::permutate);
+    }
+
+    @NotNull
+    private Stream<Arguments> permutate(Class<?> cls) {
+        return findAnnotatedMethods(cls, Testable.class, HierarchyTraversalMode.BOTTOM_UP).stream()
+                .filter(method -> allowedMethodNames.isEmpty() || allowedMethodNames.contains(method.getName()))
+                .map(toArguments(cls));
+    }
+
+    @NotNull
+    private static Function<Method, Arguments> toArguments(Class<?> cls) {
+        return method -> Arguments.of(cls.getSimpleName(), cls, method.getName());
+    }
+}