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


The following commit(s) were added to refs/heads/master by this push:
     new 8a30309  SLING-9795 - JUnit 5 support for server-side tests
8a30309 is described below

commit 8a30309ddebaa86a9edf399d410fe891c15dbf7d
Author: Julian Sedding <[email protected]>
AuthorDate: Tue Oct 6 15:12:19 2020 +0200

    SLING-9795 - JUnit 5 support for server-side tests
---
 bnd.bnd                                            |  14 +-
 pom.xml                                            |  60 ++-
 .../java/org/apache/sling/junit/RequestParser.java |  11 +-
 .../java/org/apache/sling/junit/TestsManager.java  |  41 +-
 .../java/org/apache/sling/junit/TestsProvider.java |  14 +-
 .../sling/junit/annotations/package-info.java      |   2 +-
 .../sling/junit/impl/AbstractTestsProvider.java    |  36 ++
 .../sling/junit/impl/BundleTestsProvider.java      | 250 ++++--------
 .../junit/impl/JUnit4TestExecutionStrategy.java    |  46 +++
 .../junit/impl/TestContextRunListenerWrapper.java  |  35 +-
 .../sling/junit/impl/TestExecutionStrategy.java    |  32 ++
 .../apache/sling/junit/impl/TestsManagerImpl.java  | 306 +++++++-------
 .../sling/junit/impl/servlet/HtmlRenderer.java     |  16 +-
 .../junit/impl/servlet/PlainTextRenderer.java      |  15 +-
 .../sling/junit/impl/servlet/ServletProcessor.java |  49 +--
 .../sling/junit/impl/servlet/XmlRenderer.java      |   8 +
 .../impl/servlet/junit5/DescriptionGenerator.java  |  65 +++
 .../junit/impl/servlet/junit5/FailureHelper.java   |  44 +++
 .../junit5/JUnit5TestExecutionStrategy.java        |  84 ++++
 .../junit/impl/servlet/junit5/ResultAdapter.java   |  80 ++++
 .../impl/servlet/junit5/RunListenerAdapter.java    | 136 +++++++
 .../impl/servlet/junit5/TestEngineTracker.java     |  92 +++++
 .../java/org/apache/sling/junit/package-info.java  |   2 +-
 src/main/resources/junit.css                       |   3 +
 .../impl/JUnit4TestExecutionStrategyTest.java      |  64 +++
 .../sling/junit/impl/TestsManagerImplTest.java     | 438 +++++++++++++++++----
 .../servlet/junit5/RunListenerAdapterTest.java     | 134 +++++++
 .../sling/junit/sampletests/JUnit4SlingJUnit.java  |  48 +++
 28 files changed, 1612 insertions(+), 513 deletions(-)

diff --git a/bnd.bnd b/bnd.bnd
index ccedd05..aea6f89 100644
--- a/bnd.bnd
+++ b/bnd.bnd
@@ -1,12 +1,8 @@
 Bundle-Activator: org.apache.sling.junit.Activator
-Export-Package: junit.framework;version=${junit.version}, \
-                org.junit;version=${junit.version}, \
-                org.junit.matchers.*;version=${junit.version}, \
-                org.junit.rules.*;version=${junit.version}, \
-                org.junit.runner.*;version=${junit.version}, \
-                org.junit.runners.*;version=${junit.version}, \
-                org.junit.experimental.categories.*;version=${junit.version}, \
-                org.junit.validator.*;version=${junit.version}, \
+Export-Package: !org.junit.platform.*, \
+                junit.*;version=${junit.version}, \
+                org.junit.*;version=${junit.version}, \
                 
org.hamcrest.*;version=${hamcrest.version};-split-package:=merge-first
--conditionalpackage: org.hamcrest.*, org.junit.*, junit.*
+Import-Package: org.junit.platform.*;resolution:=optional, \
+                *
 -includeresource: @org.jacoco.agent-*.jar!/org/jacoco/agent/rt/IAgent*
diff --git a/pom.xml b/pom.xml
index 2557f1b..6833e16 100644
--- a/pom.xml
+++ b/pom.xml
@@ -34,7 +34,7 @@
     <description>Runs JUnit tests in an OSGi framework and provides the JUnit 
libraries</description>
     
     <properties>
-        <junit.version>4.12</junit.version>
+        <junit.version>4.13</junit.version>
         <hamcrest.version>1.3</hamcrest.version>
         <jacoco.version>0.6.2.201302030002</jacoco.version>
     </properties>
@@ -50,10 +50,6 @@
         <plugins>
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-jar-plugin</artifactId>
-            </plugin>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-javadoc-plugin</artifactId>
                 <configuration>
                     <stylesheet>maven</stylesheet>
@@ -61,6 +57,32 @@
                 </configuration>
             </plugin>
         </plugins>
+        <pluginManagement>
+            <plugins>
+                <plugin>
+                    <groupId>biz.aQute.bnd</groupId>
+                    <artifactId>bnd-baseline-maven-plugin</artifactId>
+                    <configuration>
+                        <diffpackages>
+                            <diffpackage>!junit.*</diffpackage>
+                            <diffpackage>!org.junit.*</diffpackage>
+                            <diffpackage>*</diffpackage>
+                        </diffpackages>
+                    </configuration>
+                </plugin>
+                <plugin>
+                    <groupId>org.jacoco</groupId>
+                    <artifactId>jacoco-maven-plugin</artifactId>
+                    <configuration>
+                        <excludes>
+                            <exclude>junit/**</exclude>
+                            <exclude>org/junit/**</exclude>
+                            <exclude>org/hamcrest/**</exclude>
+                        </excludes>
+                    </configuration>
+                </plugin>
+            </plugins>
+        </pluginManagement>
     </build>
 
     <profiles>
@@ -117,6 +139,12 @@
 
     <dependencies>
         <dependency>
+            <groupId>org.jetbrains</groupId>
+            <artifactId>annotations</artifactId>
+            <version>19.0.0</version>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
             <groupId>org.osgi</groupId>
             <artifactId>org.osgi.annotation.versioning</artifactId>
         </dependency>
@@ -193,6 +221,15 @@
             <artifactId>org.apache.sling.commons.osgi</artifactId>
             <version>2.2.2</version>
         </dependency>
+
+        <!-- optional imports for JUnit 5 support -->
+        <dependency>
+            <groupId>org.junit.platform</groupId>
+            <artifactId>junit-platform-launcher</artifactId>
+            <version>1.6.2</version>
+            <scope>provided</scope>
+            <optional>true</optional>
+        </dependency>
         <!-- This bundle exposes the following dependencies at runtime, 
therefore make those dependencies available in a transitive fashion (i.e. with 
compile scope). 
              All bundles providing remote unit tests, should rely on the same 
version of JUnit and Hamcrest.
         -->
@@ -214,16 +251,17 @@
             <version>${hamcrest.version}</version>
             <scope>compile</scope>
         </dependency>
+
         <dependency>
-            <groupId>org.powermock</groupId>
-            <artifactId>powermock-module-junit4</artifactId>
-            <version>2.0.5</version>
+            <groupId>org.junit.vintage</groupId>
+            <artifactId>junit-vintage-engine</artifactId>
+            <version>5.6.2</version>
             <scope>test</scope>
         </dependency>
         <dependency>
-            <groupId>org.powermock</groupId>
-            <artifactId>powermock-api-mockito2</artifactId>
-            <version>2.0.5</version>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>3.5.7</version>
             <scope>test</scope>
         </dependency>
     </dependencies>
diff --git a/src/main/java/org/apache/sling/junit/RequestParser.java 
b/src/main/java/org/apache/sling/junit/RequestParser.java
index d680a2c..f8a0223 100644
--- a/src/main/java/org/apache/sling/junit/RequestParser.java
+++ b/src/main/java/org/apache/sling/junit/RequestParser.java
@@ -77,12 +77,17 @@ public class RequestParser implements TestSelector {
 
     public String toString() {
         return getClass().getSimpleName() 
-                + ", testSelector [" + testNameSelector + "]"
-                + ", methodName [" + selectedMethodName + "]"
-                + ", extension [" + extension + "]"
+                + ", testSelector [" + safeForLogging(testNameSelector) + "]"
+                + ", methodName [" + safeForLogging(selectedMethodName) + "]"
+                + ", extension [" + safeForLogging(extension) + "]"
                 ;
     }
 
+    private static String safeForLogging(String str) {
+        // protect against logging injection attacks
+        return str.replaceAll("[\n\r\t]", "_");
+    }
+
     public String getTestSelectorString() {
         return testNameSelector;
     }
diff --git a/src/main/java/org/apache/sling/junit/TestsManager.java 
b/src/main/java/org/apache/sling/junit/TestsManager.java
index 497ad0b..76f4781 100644
--- a/src/main/java/org/apache/sling/junit/TestsManager.java
+++ b/src/main/java/org/apache/sling/junit/TestsManager.java
@@ -18,6 +18,8 @@ package org.apache.sling.junit;
 
 import java.util.Collection;
 
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 import org.osgi.annotation.versioning.ProviderType;
 
 /**
@@ -31,13 +33,7 @@ public interface TestsManager {
      * @param selector if null, returns all available tests.
      * @return the name of the tests
      */
-    Collection<String> getTestNames(TestSelector selector);
-
-    /**
-     * Clear our internal caches. Useful in automated testing, to make sure 
changes introduced by recent uploads or configuration or bundles
-     * changes are taken into account immediately.
-     */
-    void clearCaches();
+    Collection<String> getTestNames(@Nullable TestSelector selector);
 
     /**
      * Instantiate test class for specified test
@@ -46,7 +42,7 @@ public interface TestsManager {
      * @return an instance of the class
      * @throws ClassNotFoundException if a class for {@code testName} cannot 
be found
      */
-    Class<?> getTestClass(String testName) throws ClassNotFoundException;
+    Class<?> getTestClass(@NotNull String testName) throws 
ClassNotFoundException;
 
     /**
      * List tests using supplied Renderer - does NOT call setup or cleanup on 
renderer.
@@ -55,7 +51,17 @@ public interface TestsManager {
      * @param renderer  the renderer to use
      * @throws Exception if any error occurs
      */
-    void listTests(Collection<String> testNames, Renderer renderer) throws 
Exception;
+    void listTests(@NotNull Collection<String> testNames, @NotNull Renderer 
renderer) throws Exception;
+
+    /**
+     * Execute tests and report results using supplied Renderer - does NOT 
call setup or cleanup on renderer.
+     *
+     * @param renderer  the renderer to use for the reporting
+     * @param selector  the selector used to select tests and test methods; 
all tests are executed if this is null
+     * @throws NoTestCasesFoundException if no tests matching the selector are 
available
+     * @throws Exception if an error occurs
+     */
+    void executeTests(@NotNull Renderer renderer, @Nullable TestSelector 
selector) throws NoTestCasesFoundException, Exception;
 
     /**
      * Execute tests and report results using supplied Renderer - does NOT 
call setup or cleanup on renderer.
@@ -64,6 +70,21 @@ public interface TestsManager {
      * @param renderer  the renderer to use for the reporting
      * @param selector  the selector used to select tests and test methods (it 
can be {@code null})
      * @throws Exception if any error occurs
+     *
+     * @deprecated use {@link #executeTests(Renderer, TestSelector)} instead
+     */
+    @Deprecated
+    void executeTests(@Nullable Collection<String> testNames, @NotNull 
Renderer renderer, @Nullable TestSelector selector)
+            throws Exception;
+
+    /**
+     * Clear our internal caches. Useful in automated testing, to make sure 
changes introduced by recent uploads or configuration or bundles
+     * changes are taken into account immediately.
+     *
+     * @deprecated Caches have been removed.
      */
-    void executeTests(Collection<String> testNames, Renderer renderer, 
TestSelector selector) throws Exception;
+    @Deprecated
+    void clearCaches();
+
+    class NoTestCasesFoundException extends RuntimeException {}
 }
diff --git a/src/main/java/org/apache/sling/junit/TestsProvider.java 
b/src/main/java/org/apache/sling/junit/TestsProvider.java
index 31e0a69..2e36f79 100644
--- a/src/main/java/org/apache/sling/junit/TestsProvider.java
+++ b/src/main/java/org/apache/sling/junit/TestsProvider.java
@@ -25,8 +25,11 @@ public interface TestsProvider {
     /**
      * Return this service's PID, client might use it later to instantiate a 
specific test.
      *
-     * @return the service pid
+     * @return the service pid or null
+     *
+     * @deprecated No longer used.
      */
+    @Deprecated
     String getServicePid();
 
     /**
@@ -49,7 +52,14 @@ public interface TestsProvider {
     /**
      * Return the timestamp at which our list of tests was last modified
      *
-     * @return the last modified date of the tests list as a timestamp
+     * @return the last modified date of the tests list as a timestamp or -1 
if not supported
+     *
+     * @deprecated No longer used. {@code TestManager} always gets the latest 
tests
+     *  from the {@code TestsProvider} instances. Any performance issues need 
to be
+     *  addressed inside the {@code TestsProvider} implementation, e.g. by
+     *  caching.
      */
+    @Deprecated
     long lastModified();
+
 }
diff --git a/src/main/java/org/apache/sling/junit/annotations/package-info.java 
b/src/main/java/org/apache/sling/junit/annotations/package-info.java
index 46f3101..f265cce 100644
--- a/src/main/java/org/apache/sling/junit/annotations/package-info.java
+++ b/src/main/java/org/apache/sling/junit/annotations/package-info.java
@@ -16,7 +16,7 @@
  ~ specific language governing permissions and limitations
  ~ under the License.
  
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
-@Version("1.0.8")
+@Version("1.1.0")
 package org.apache.sling.junit.annotations;
 
 import org.osgi.annotation.versioning.Version;
diff --git 
a/src/main/java/org/apache/sling/junit/impl/AbstractTestsProvider.java 
b/src/main/java/org/apache/sling/junit/impl/AbstractTestsProvider.java
new file mode 100644
index 0000000..815ae32
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/AbstractTestsProvider.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.impl;
+
+import org.apache.sling.junit.TestsProvider;
+
+/**
+ * To help with backwards compatibility of deprecated methods.
+ */
+public abstract class AbstractTestsProvider implements TestsProvider {
+    @Override
+    public String getServicePid() {
+        return null;
+    }
+
+    @Override
+    public long lastModified() {
+        return -1;
+    }
+}
diff --git a/src/main/java/org/apache/sling/junit/impl/BundleTestsProvider.java 
b/src/main/java/org/apache/sling/junit/impl/BundleTestsProvider.java
index 55a47f5..de7b785 100644
--- a/src/main/java/org/apache/sling/junit/impl/BundleTestsProvider.java
+++ b/src/main/java/org/apache/sling/junit/impl/BundleTestsProvider.java
@@ -17,21 +17,28 @@
 package org.apache.sling.junit.impl;
 
 import java.net.URL;
-import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.Enumeration;
-import java.util.HashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import java.util.stream.Collectors;
 
 import org.apache.sling.junit.TestsProvider;
+import org.jetbrains.annotations.NotNull;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.BundleEvent;
-import org.osgi.framework.BundleListener;
-import org.osgi.service.component.ComponentContext;
+import org.osgi.framework.Constants;
+import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
+import org.osgi.util.tracker.BundleTracker;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -39,191 +46,108 @@ import org.slf4j.LoggerFactory;
  *  that have a Sling-Test-Regexp header and corresponding
  *  exported classes.
  */
-@Component
-public class BundleTestsProvider implements TestsProvider, BundleListener {
-    private final Logger log = LoggerFactory.getLogger(getClass());
-private static final String COMPONENT_NAME = "component.name";
-    private long lastModified;
-    private BundleContext bundleContext;
-    private String componentName;
-    
+@Component(service = TestsProvider.class)
+public class BundleTestsProvider extends AbstractTestsProvider {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(BundleTestsProvider.class);
+
     public static final String SLING_TEST_REGEXP = "Sling-Test-Regexp";
     
-    /** Symbolic names of bundles that changed state - if not empty, need
-     *  to adjust the list of tests
-     */
-    private final List<String> changedBundles = new ArrayList<String>();
-    
-    /** List of (candidate) test classes, keyed by bundle so that we can
-     *  update them easily when bundles come and go 
-     */
-    private final Map<String, List<String>> testClassesMap = new 
HashMap<String, List<String>>();
-
-    protected void activate(ComponentContext ctx) {
-        bundleContext = ctx.getBundleContext();
-        bundleContext.addBundleListener(this);
-        
-        // Initially consider all bundles as "changed"
-        for(Bundle b : bundleContext.getBundles()) {
-            if(getSlingTestRegexp(b) != null) {
-                changedBundles.add(b.getSymbolicName());
-                log.debug("Will look for test classes inside bundle {}", 
b.getSymbolicName());
-            }
+    private TestClassesTracker tracker;
+
+    @Activate
+    protected void activate(BundleContext ctx) {
+        tracker = new TestClassesTracker(ctx);
+        tracker.open();
+    }
+
+    @Deactivate
+    protected void deactivate() {
+        if (tracker != null) {
+            tracker.close();
+            tracker = null;
         }
-        
-        lastModified = System.currentTimeMillis();
-        componentName = (String)ctx.getProperties().get(COMPONENT_NAME);
     }
-    
-    protected void deactivate(ComponentContext ctx) {
-        bundleContext.removeBundleListener(this);
-        bundleContext = null;
-        changedBundles.clear();
+
+    public Class<?> createTestClass(String testName) throws 
ClassNotFoundException {
+        final Bundle bundle = tracker.getTracked().entrySet().stream()
+                .filter(entry -> entry.getValue().contains(testName))
+                .map(Map.Entry::getKey)
+                .findFirst()
+                .orElseThrow(() -> new ClassNotFoundException("No Bundle found 
that supplies test class " + testName));
+        return bundle.loadClass(testName);
     }
-    
-    @Override
-    public String toString() {
-        return getClass().getSimpleName() + ", componentName(pid)=" + 
componentName;
+
+    public List<String> getTestNames() {
+        return tracker.getTracked().values().stream()
+                .flatMap(Collection::stream)
+                .collect(Collectors.toList());
     }
 
-    /** Update testClasses if bundle changes require it */
-    private void maybeUpdateTestClasses() {
-        if(changedBundles.isEmpty()) {
-            return;
+    private static class TestClassesTracker extends BundleTracker<Set<String>> 
{
+        public TestClassesTracker(BundleContext ctx) {
+            super(ctx, Bundle.ACTIVE, null);
         }
 
-        // Get the list of bundles that have changed
-        final List<String> bundlesToUpdate = new ArrayList<String>();
-        synchronized (changedBundles) {
-            bundlesToUpdate.addAll(changedBundles);
-            changedBundles.clear();
-        }
-        
-        // Remove test classes that belong to changed bundles
-        for(String symbolicName : bundlesToUpdate) {
-            testClassesMap.remove(symbolicName);
-        }
-        
-        // Get test classes from bundles that are in our list
-        for(Bundle b : bundleContext.getBundles()) {
-            if(bundlesToUpdate.contains(b.getSymbolicName())) {
-                final List<String> testClasses = getTestClasses(b);
-                if(testClasses != null) {
-                    testClassesMap.put(b.getSymbolicName(), testClasses);
-                    log.debug("{} test classes found in bundle {}, added to 
our list", 
-                            testClasses.size(), b.getSymbolicName());
-                } else {
-                    log.debug("No test classes found in bundle {}", 
b.getSymbolicName());
-                }
+        @Override
+        public Set<String> addingBundle(Bundle bundle, BundleEvent event) {
+            super.addingBundle(bundle, event);
+            if (isFragment(bundle)) {
+                return null;
             }
+            final Set<String> testClasses = getTestClasses(bundle);
+            return testClasses.isEmpty() ? null : testClasses;
         }
-    }
 
-    /** Called when a bundle changes state */
-    public void bundleChanged(BundleEvent event) {
-        // Only consider bundles which contain tests
-        final Bundle b = event.getBundle();
-        if(getSlingTestRegexp(b) == null) {
-            log.debug("Bundle {} does not have {} header, ignored", 
-                    b.getSymbolicName(), SLING_TEST_REGEXP);
-            return;
-        }
-        synchronized (changedBundles) {
-            log.debug("Got BundleEvent for Bundle {}, will rebuild its lists 
of tests");
-            changedBundles.add(b.getSymbolicName());
-        }
-        lastModified = System.currentTimeMillis();
-    }
-    
-    private String getSlingTestRegexp(Bundle b) {
-        return (String)b.getHeaders().get(SLING_TEST_REGEXP);
-    }
-    
-    /** Get test classes that bundle b provides (as done in Felix/Sigil) */
-    private List<String> getTestClasses(Bundle b) {
-        final List<String> result = new ArrayList<String>();
-        Pattern testClassRegexp = null;
-        final String headerValue = getSlingTestRegexp(b);
-        if (headerValue != null) {
-            try {
-                testClassRegexp = Pattern.compile(headerValue);
+        /** Get test classes that bundle b provides (as done in Felix/Sigil) */
+        @NotNull
+        private static Set<String> getTestClasses(Bundle bundle) {
+            final String headerValue = getSlingTestRegexp(bundle);
+            if (headerValue == null) {
+                LOG.debug("Bundle '{}' does not have {} header, not looking 
for test classes",
+                        bundle.getSymbolicName(), SLING_TEST_REGEXP);
+                return Collections.emptySet();
             }
-            catch (PatternSyntaxException pse) {
-                log.warn("Invalid pattern '" + headerValue + "' for bundle "
-                                + b.getSymbolicName() + ", ignored", pse);
+
+            Predicate<String> isTestClass;
+            try {
+                isTestClass = Pattern.compile(headerValue).asPredicate();
+            } catch (PatternSyntaxException pse) {
+                LOG.warn("Bundle '{}' has an invalid pattern for {} header, 
ignored: '{}'",
+                        bundle.getSymbolicName(), SLING_TEST_REGEXP, 
headerValue);
+                return Collections.emptySet();
             }
-        }
-        
-        if (testClassRegexp == null) {
-            log.info("Bundle {} does not have {} header, not looking for test 
classes", SLING_TEST_REGEXP);
-        } else if (Bundle.ACTIVE != b.getState()) {
-            log.info("Bundle {} is not active, no test classes considered", 
b.getSymbolicName());
-        } else {
-            @SuppressWarnings("unchecked")
-            Enumeration<URL> classUrls = b.findEntries("", "*.class", true);
+
+            Enumeration<URL> classUrls = bundle.findEntries("", "*.class", 
true);
+            final Set<String> result = new LinkedHashSet<>();
             while (classUrls.hasMoreElements()) {
                 URL url = classUrls.nextElement();
                 final String name = toClassName(url);
-                if(testClassRegexp.matcher(name).matches()) {
+                if(isTestClass.test(name)) {
                     result.add(name);
                 } else {
-                    log.debug("Class {} does not match {} pattern {} of bundle 
{}, ignored",
-                            new Object[] { name, SLING_TEST_REGEXP, 
testClassRegexp, b.getSymbolicName() });
+                    LOG.debug("Class '{}' does not match {} pattern '{}' of 
bundle '{}', ignored",
+                            name, SLING_TEST_REGEXP, headerValue, 
bundle.getSymbolicName());
                 }
             }
-            log.info("{} test classes found in bundle {}", result.size(), 
b.getSymbolicName());
-        }
-        
-        return result;
-    }
-    
-    /** Convert class URL to class name */
-    private String toClassName(URL url) {
-        final String f = url.getFile();
-        final String cn = f.substring(1, f.length() - ".class".length());
-        return cn.replace('/', '.');
-    }
 
-    /** Find bundle by symbolic name */
-    private Bundle findBundle(String symbolicName) {
-        for(Bundle b : bundleContext.getBundles()) {
-            if(b.getSymbolicName().equals(symbolicName)) {
-                return b;
-            }
+            LOG.info("{} test classes found in bundle '{}'", result.size(), 
bundle.getSymbolicName());
+            return result;
         }
-        return null;
-    }
-    
-    public Class<?> createTestClass(String testName) throws 
ClassNotFoundException {
-        // Find the bundle to which the class belongs
-        Bundle b = null;
-        for(Map.Entry<String, List<String>> e : testClassesMap.entrySet()) {
-            if(e.getValue().contains(testName)) {
-                b = findBundle(e.getKey());
-                break;
-            }
-        }
-        
-        if(b == null) {
-            throw new IllegalArgumentException("No Bundle found that supplies 
test class " + testName);
-        }
-        return b.loadClass(testName);
-    }
 
-    public long lastModified() {
-        return lastModified;
-    }
+        private static String getSlingTestRegexp(Bundle bundle) {
+            return bundle.getHeaders().get(SLING_TEST_REGEXP);
+        }
 
-    public String getServicePid() {
-        return componentName;
-    }
+        /** Convert class URL to class name */
+        private static String toClassName(URL url) {
+            final String f = url.getFile();
+            final String cn = f.substring(1, f.length() - ".class".length());
+            return cn.replace('/', '.');
+        }
 
-    public List<String> getTestNames() {
-        maybeUpdateTestClasses();
-        final List<String> result = new ArrayList<String>();
-        for(List<String> list : testClassesMap.values()) {
-            result.addAll(list);
+        private static boolean isFragment(final Bundle bundle) {
+            return bundle.getHeaders().get(Constants.FRAGMENT_HOST) != null;
         }
-        return result;
     }
 }
diff --git 
a/src/main/java/org/apache/sling/junit/impl/JUnit4TestExecutionStrategy.java 
b/src/main/java/org/apache/sling/junit/impl/JUnit4TestExecutionStrategy.java
new file mode 100644
index 0000000..037e221
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/JUnit4TestExecutionStrategy.java
@@ -0,0 +1,46 @@
+/*
+ * 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;
+
+import org.apache.sling.junit.TestSelector;
+import org.junit.runner.JUnitCore;
+import org.junit.runner.Request;
+import org.junit.runner.notification.RunListener;
+
+public class JUnit4TestExecutionStrategy implements TestExecutionStrategy {
+
+    private final TestsManagerImpl testsManager;
+
+    public JUnit4TestExecutionStrategy(TestsManagerImpl testsManager) {
+        this.testsManager = testsManager;
+    }
+
+    @Override
+    public void execute(TestSelector selector, RunListener runListener) throws 
Exception {
+        final JUnitCore junit = new JUnitCore();
+        junit.addListener(runListener);
+        final Request request = testsManager.createTestRequest(selector, 
Request::method, Request::classes);
+        junit.run(request);
+    }
+
+    @Override
+    public void close() {
+        // nothing to do
+    }
+}
diff --git 
a/src/main/java/org/apache/sling/junit/impl/TestContextRunListenerWrapper.java 
b/src/main/java/org/apache/sling/junit/impl/TestContextRunListenerWrapper.java
index 0f264ee..544524b 100644
--- 
a/src/main/java/org/apache/sling/junit/impl/TestContextRunListenerWrapper.java
+++ 
b/src/main/java/org/apache/sling/junit/impl/TestContextRunListenerWrapper.java
@@ -28,8 +28,9 @@ public class TestContextRunListenerWrapper extends 
RunListener {
     private final RunListener wrapped;
     private long testStartTime;
     private static final Logger log = 
LoggerFactory.getLogger(TestContextRunListenerWrapper.class);
-    
-    TestContextRunListenerWrapper(RunListener toWrap) {
+    private boolean createContext;
+
+    public TestContextRunListenerWrapper(RunListener toWrap) {
         wrapped = toWrap;
     }
 
@@ -58,13 +59,21 @@ public class TestContextRunListenerWrapper extends 
RunListener {
     }
 
     @Override
-    public void testRunFinished(Result result) throws Exception {
-        wrapped.testRunFinished(result);
+    public void testRunStarted(Description description) throws Exception {
+        // Create a test context if we don't have one yet
+        createContext = !SlingTestContextProvider.hasContext();
+        if(createContext) {
+            SlingTestContextProvider.createContext();
+        }
+        wrapped.testRunStarted(description);
     }
 
     @Override
-    public void testRunStarted(Description description) throws Exception {
-        wrapped.testRunStarted(description);
+    public void testRunFinished(Result result) throws Exception {
+        wrapped.testRunFinished(result);
+        if (createContext) {
+            SlingTestContextProvider.deleteContext();
+        }
     }
 
     @Override
@@ -72,4 +81,18 @@ public class TestContextRunListenerWrapper extends 
RunListener {
         testStartTime = System.currentTimeMillis();
         wrapped.testStarted(description);
     }
+
+    @Override
+    public void testSuiteStarted(Description description) throws Exception {
+        // If we have a test context, clear its output metadata
+        if (SlingTestContextProvider.hasContext()) {
+            SlingTestContextProvider.getContext().output().clear();
+        }
+        wrapped.testSuiteStarted(description);
+    }
+
+    @Override
+    public void testSuiteFinished(Description description) throws Exception {
+        wrapped.testSuiteFinished(description);
+    }
 }
diff --git 
a/src/main/java/org/apache/sling/junit/impl/TestExecutionStrategy.java 
b/src/main/java/org/apache/sling/junit/impl/TestExecutionStrategy.java
new file mode 100644
index 0000000..3ea034a
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/TestExecutionStrategy.java
@@ -0,0 +1,32 @@
+/*
+ * 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;
+
+import org.apache.sling.junit.TestSelector;
+import org.junit.runner.notification.RunListener;
+
+import java.io.Closeable;
+
+public interface TestExecutionStrategy extends Closeable {
+
+    void execute(TestSelector selector, RunListener runListener) throws 
Exception;
+
+    @Override
+    void close();
+}
diff --git a/src/main/java/org/apache/sling/junit/impl/TestsManagerImpl.java 
b/src/main/java/org/apache/sling/junit/impl/TestsManagerImpl.java
index 82b5ab8..80b8f7e 100644
--- a/src/main/java/org/apache/sling/junit/impl/TestsManagerImpl.java
+++ b/src/main/java/org/apache/sling/junit/impl/TestsManagerImpl.java
@@ -16,36 +16,35 @@
  */
 package org.apache.sling.junit.impl;
 
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.TimeUnit;
-
-import org.apache.sling.junit.Activator;
 import org.apache.sling.junit.Renderer;
-import org.apache.sling.junit.SlingTestContextProvider;
+import org.apache.sling.junit.RequestParser;
 import org.apache.sling.junit.TestSelector;
 import org.apache.sling.junit.TestsManager;
 import org.apache.sling.junit.TestsProvider;
-import org.junit.runner.JUnitCore;
-import org.junit.runner.Request;
+import org.apache.sling.junit.impl.servlet.junit5.JUnit5TestExecutionStrategy;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 import org.osgi.framework.Bundle;
 import org.osgi.framework.BundleContext;
 import org.osgi.framework.Constants;
-import org.osgi.framework.ServiceReference;
-import org.osgi.service.component.ComponentContext;
+import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Deactivate;
 import org.osgi.util.tracker.ServiceTracker;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.function.BiFunction;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
 @Component
 public class TestsManagerImpl implements TestsManager {
 
@@ -54,174 +53,136 @@ public class TestsManagerImpl implements TestsManager {
     // Global Timeout up to which it stop waiting for bundles to be all 
active, default to 40 seconds.
     public static final String PROP_STARTUP_TIMEOUT_SECONDS = 
"sling.junit.core.SystemStartupTimeoutSeconds";
 
-    private static volatile int startupTimeoutSeconds = 
Integer.parseInt(System.getProperty(PROP_STARTUP_TIMEOUT_SECONDS, "40"));
-
-    private static volatile boolean waitForSystemStartup = true;
+    private final int startupTimeoutSeconds = 
Integer.parseInt(System.getProperty(PROP_STARTUP_TIMEOUT_SECONDS, "40"));
 
-    private ServiceTracker tracker;
+    private volatile boolean waitForSystemStartup = true;
 
-    private int lastTrackingCount = -1;
+    boolean isReady() {
+        return !waitForSystemStartup;
+    }
 
     private BundleContext bundleContext;
+
+    private ServiceTracker<TestsProvider, TestsProvider> testsProviderTracker;
     
-    // List of providers
-    private final List<TestsProvider> providers = new 
ArrayList<TestsProvider>();
-    
-    // Map of test names to their provider's PID
-    private Map<String, String> tests = new ConcurrentHashMap<String, 
String>();
-    
-    // Last-modified values for each provider
-    private Map<String, Long> lastModified = new HashMap<String, Long>();
-    
-    protected void activate(ComponentContext ctx) {
-        bundleContext = ctx.getBundleContext();
-        tracker = new ServiceTracker(bundleContext, 
TestsProvider.class.getName(), null);
-        tracker.open();
-    }
+    private TestExecutionStrategy executionStrategy;
 
-    protected void deactivate(ComponentContext ctx) {
-        if(tracker != null) {
-            tracker.close();
+    @Activate
+    protected void activate(BundleContext ctx) {
+        bundleContext = ctx;
+        testsProviderTracker = new ServiceTracker<>(bundleContext, 
TestsProvider.class, null);
+        testsProviderTracker.open();
+        try {
+            executionStrategy = new JUnit5TestExecutionStrategy(this, ctx);
+        } catch (NoClassDefFoundError e) {
+            // (some) optional imports to org.junit.platform.* (JUnit5 API) 
are missing
+            executionStrategy = new JUnit4TestExecutionStrategy(this);
         }
-        tracker = null;
-        bundleContext = null;
     }
-    
-    public void clearCaches() {
-        log.debug("Clearing internal caches");
-        lastModified.clear();
-        lastTrackingCount = -1;
-    }
-    
-    public Class<?> getTestClass(String testName) throws 
ClassNotFoundException {
-        maybeUpdateProviders();
 
-        // find TestsProvider that can instantiate testName
-        final String providerPid = tests.get(testName);
-        if(providerPid == null) {
-            throw new IllegalStateException("Provider PID not found for test " 
+ testName);
+    @Deactivate
+    protected void deactivate() {
+        if(testsProviderTracker != null) {
+            testsProviderTracker.close();
+            testsProviderTracker = null;
         }
-        TestsProvider provider = null;
-        for(TestsProvider p : providers) {
-            if(p.getServicePid().equals(providerPid)) {
-                provider = p;
-                break;
-            }
-        }
-        
-        if(provider == null) {
-            throw new IllegalStateException("No TestsProvider found for PID " 
+ providerPid);
+
+        if (executionStrategy != null) {
+            executionStrategy.close();
+            executionStrategy = null;
         }
 
+        bundleContext = null;
+    }
+
+    @NotNull
+    public Class<?> getTestClass(@NotNull String testName) throws 
ClassNotFoundException {
+        final TestsProvider provider = getTestProviders()
+                .filter(p -> p.getTestNames().contains(testName))
+                .findFirst()
+                .orElseThrow(() -> new ClassNotFoundException("No 
TestsProvider found for test '" + testName + "'"));
+
         log.debug("Using provider {} to create test class {}", provider, 
testName);
         return provider.createTestClass(testName);
     }
 
-    public Collection<String> getTestNames(TestSelector selector) {
-        maybeUpdateProviders();
-        
-        // If any provider has changes, reload the whole list
-        // of test names (to keep things simple)
-        boolean reload = false;
-        for(TestsProvider p : providers) {
-            final Long lastMod = lastModified.get(p.getServicePid());
-            if(lastMod == null || lastMod != p.lastModified()) {
-                reload = true;
-                log.debug("{} updated, will reload test names from all 
providers", p);
-                break;
-            }
-        }
-        
-        if(reload) {
-            tests.clear();
-            for(TestsProvider p : providers) {
-                final String pid = p.getServicePid();
-                if(pid == null) {
-                    log.warn("{} has null PID, ignored", p);
-                    continue;
-                }
-                lastModified.put(pid, p.lastModified());
-                final List<String> names = p.getTestNames(); 
-                for(String name : names) {
-                    tests.put(name, pid);
-                }
-                log.debug("Added {} test names from provider {}", 
names.size(), p);
-            }
-            log.info("Test names reloaded, total {} names from {} providers", 
tests.size(), providers.size());
-        }
-        
-        final Collection<String> allTests = tests.keySet();
+    @Override
+    public Collection<String> getTestNames(@Nullable TestSelector selector) {
+        final List<String> tests = getTestProviders()
+                .map(TestsProvider::getTestNames)
+                .flatMap(Collection::stream)
+                .sorted()
+                .collect(Collectors.toList());
+        final int allTestsCount = tests.size();
         if(selector == null) {
-            log.debug("No TestSelector supplied, returning all {} tests", 
allTests.size());
-            return allTests;
+            log.debug("No TestSelector supplied, returning all {} tests", 
allTestsCount);
         } else {
-            final List<String> result = new LinkedList<String>();
-            for(String test : allTests) {
-                if(selector.acceptTestName(test)) {
-                    result.add(test);
-                }
-            }
-            log.debug("{} selected {} tests out of {}", selector, 
result.size(), allTests.size());
-            return result;
+            tests.removeIf(testName -> !selector.acceptTestName(testName));
+            log.debug("{} selected {} tests out of {}", selector, 
tests.size(), allTestsCount);
         }
+        return tests;
     }
-    
-    /** Update our list of providers if tracker changed */
-    private void maybeUpdateProviders() {
-        if(tracker.getTrackingCount() != lastTrackingCount) {
-            // List of providers changed, need to reload everything
-            lastModified.clear();
-            List<TestsProvider> newList = new ArrayList<TestsProvider>();
-            for(ServiceReference ref : tracker.getServiceReferences()) {
-                newList.add((TestsProvider)bundleContext.getService(ref));
-            }
-            synchronized (providers) {
-                providers.clear();
-                providers.addAll(newList);
-            }
-            log.info("Updated list of TestsProvider: {}", providers);
+
+    private Stream<TestsProvider> getTestProviders() {
+        return testsProviderTracker.getTracked().values().stream();
+    }
+
+    @Override
+    public void executeTests(@Nullable Collection<String> testNames, @NotNull 
Renderer renderer, @Nullable TestSelector selector) throws Exception {
+        if (selector != null) {
+            executeTests(renderer, selector);
+        } else if (testNames != null){
+            executeTests(renderer, new RequestParser(null) {
+                @Override
+                public boolean acceptTestName(String testName) {
+                    return testNames.contains(testName);
+                }
+            });
+        } else {
+            executeTests(renderer, null);
         }
-        lastTrackingCount = tracker.getTrackingCount();
     }
 
-    public void executeTests(Collection<String> testNames, Renderer renderer, 
TestSelector selector) throws Exception {
+    @Override
+    public void executeTests(@NotNull Renderer renderer, @Nullable 
TestSelector selector) throws Exception {
         renderer.title(2, "Running tests");
         waitForSystemStartup();
-        final JUnitCore junit = new JUnitCore();
-        
-        // Create a test context if we don't have one yet
-        final boolean createContext =  !SlingTestContextProvider.hasContext();
-        if(createContext) {
-            SlingTestContextProvider.createContext();
+        executionStrategy.execute(selector, new 
TestContextRunListenerWrapper(renderer.getRunListener()));
+    }
+
+    public <T> T createTestRequest(TestSelector selector,
+                            BiFunction<Class<?>, String, T> 
methodRequestFactory,
+                            Function<Class<?>[], T> classesRequestFactory) 
throws ClassNotFoundException {
+        final T request;
+        final Collection<String> testNames = getTestNames(selector);
+        if (testNames.isEmpty()) {
+            throw new NoTestCasesFoundException();
         }
-        
-        try {
-            junit.addListener(new 
TestContextRunListenerWrapper(renderer.getRunListener()));
-            for(String className : testNames) {
-                renderer.title(3, className);
-                
-                // If we have a test context, clear its output metadata
-                if(SlingTestContextProvider.hasContext()) {
-                    SlingTestContextProvider.getContext().output().clear();
-                }
-                
-                final String testMethodName = selector == null ? null : 
selector.getSelectedTestMethodName();
-                if(testMethodName != null && testMethodName.length() > 0) {
-                    log.debug("Running test method {} from test class {}", 
testMethodName, className);
-                    junit.run(Request.method(getTestClass(className), 
testMethodName));
-                } else {
-                    log.debug("Running test class {}", className);
-                    junit.run(getTestClass(className));
-                }
+        final String testMethodName = selector == null ? null : 
selector.getSelectedTestMethodName();
+        if (testNames.size() == 1 && isNotBlank(testMethodName)) {
+            final String className = testNames.iterator().next();
+            log.debug("Running test method {} from test class {}", 
testMethodName, className);
+            request = methodRequestFactory.apply(getTestClass(className), 
testMethodName);
+        } else {
+            if (isNotBlank(testMethodName)) {
+                throw new IllegalStateException("A test method name is only 
supported for a single test class");
             }
-        } finally {
-            if(createContext) {
-                SlingTestContextProvider.deleteContext();
+            final List<Class<?>> testClasses = new ArrayList<>();
+            for (String className : testNames) {
+                log.debug("Running test class {}", className);
+                testClasses.add(getTestClass(className));
             }
+            request = classesRequestFactory.apply(testClasses.toArray(new 
Class[0]));
         }
+        return request;
+    }
+
+    private static boolean isNotBlank(String str) {
+        return str != null && str.length() > 0;
     }
 
-    public void listTests(Collection<String> testNames, Renderer renderer) {
+    @Override
+    public void listTests(@NotNull Collection<String> testNames, @NotNull 
Renderer renderer) {
         renderer.title(2, "Test classes");
         final String note = "The test set can be restricted using partial test 
names"
                 + " as a suffix to this URL"
@@ -230,21 +191,21 @@ public class TestsManagerImpl implements TestsManager {
         renderer.list("testNames", testNames);
     }
 
+    @Override
+    public void clearCaches() {
+        // deprecated method kept for backwards compatibility
+    }
 
     /** Wait for all bundles to be started
      *  @return number of msec taken by this method to execute
     */
-    public static long waitForSystemStartup() {
+    long waitForSystemStartup() {
         long elapsedMsec = -1;
         if (waitForSystemStartup) {
             waitForSystemStartup = false;
-            final BundleContext bundleContext = Activator.getBundleContext();
-            final Set<Bundle> bundlesToWaitFor = new HashSet<Bundle>();
-            for (final Bundle bundle : bundleContext.getBundles()) {
-                if (bundle.getState() != Bundle.ACTIVE && !isFragment(bundle)) 
{
-                    bundlesToWaitFor.add(bundle);
-                }
-            }
+            final Set<Bundle> bundlesToWaitFor = 
Stream.of(bundleContext.getBundles())
+                    
.filter(not(TestsManagerImpl::isActive).and(not(TestsManagerImpl::isFragment)))
+                    .collect(Collectors.toSet());
 
             // wait max inactivityTimeout after the last bundle became active 
before giving up
             final long startTime = System.currentTimeMillis();
@@ -256,14 +217,7 @@ public class TestsManagerImpl implements TestsManager {
                 } catch (InterruptedException e) {
                     Thread.currentThread().interrupt();
                 }
-                Iterator<Bundle> bundles = bundlesToWaitFor.iterator();
-                while (bundles.hasNext()) {
-                    Bundle bundle = bundles.next();
-                    if (bundle.getState() == Bundle.ACTIVE) {
-                        bundles.remove();
-                        log.debug("Bundle {} is now active", 
bundle.getSymbolicName());
-                    }
-                }
+                bundlesToWaitFor.removeIf(TestsManagerImpl::isActive);
             }
 
             elapsedMsec = System.currentTimeMillis() - startTime;
@@ -280,11 +234,19 @@ public class TestsManagerImpl implements TestsManager {
         return elapsedMsec;
     }
 
-    private static boolean needToWait(final long startupTimeout, final 
Collection<Bundle> bundlesToWaitFor) {
+    static boolean needToWait(final long startupTimeout, final 
Collection<Bundle> bundlesToWaitFor) {
         return startupTimeout > System.currentTimeMillis() && 
!bundlesToWaitFor.isEmpty();
     }
 
+    private static <T> Predicate<T> not(Predicate<T> predicate) {
+        return predicate.negate();
+    }
+
     private static boolean isFragment(final Bundle bundle) {
         return bundle.getHeaders().get(Constants.FRAGMENT_HOST) != null;
     }
+
+    private static boolean isActive(Bundle bundle) {
+        return bundle.getState() == Bundle.ACTIVE;
+    }
 }
diff --git 
a/src/main/java/org/apache/sling/junit/impl/servlet/HtmlRenderer.java 
b/src/main/java/org/apache/sling/junit/impl/servlet/HtmlRenderer.java
index c0b2ce0..fc00239 100644
--- a/src/main/java/org/apache/sling/junit/impl/servlet/HtmlRenderer.java
+++ b/src/main/java/org/apache/sling/junit/impl/servlet/HtmlRenderer.java
@@ -168,14 +168,6 @@ public class HtmlRenderer extends RunListener implements 
Renderer,RendererFactor
     @Override
     public void testRunFinished(Result result) throws Exception {
         super.testRunFinished(result);
-        String cssClass = "testRun ";
-        if(result.getFailureCount() > 0) {
-            cssClass += "failure";
-        } else if(result.getIgnoreCount() > 0) {
-            cssClass += "ignored";
-        } else {
-            cssClass += "success";
-        }
 
         output.println("<p class='testRun'>");
         output.print("TEST RUN FINISHED: ");
@@ -188,9 +180,11 @@ public class HtmlRenderer extends RunListener implements 
Renderer,RendererFactor
     }
 
     @Override
-    public void testRunStarted(Description description)
-            throws Exception {
-        super.testRunStarted(description);
+    public void testSuiteStarted(Description description) throws Exception {
+        super.testSuiteStarted(description);
+        if (description.getTestClass() != null) {
+            title(3, description.getClassName());
+        }
     }
 
     @Override
diff --git 
a/src/main/java/org/apache/sling/junit/impl/servlet/PlainTextRenderer.java 
b/src/main/java/org/apache/sling/junit/impl/servlet/PlainTextRenderer.java
index 937bc72..d881597 100644
--- a/src/main/java/org/apache/sling/junit/impl/servlet/PlainTextRenderer.java
+++ b/src/main/java/org/apache/sling/junit/impl/servlet/PlainTextRenderer.java
@@ -37,7 +37,7 @@ import org.osgi.service.component.annotations.Component;
 public class PlainTextRenderer extends RunListener implements Renderer, 
RendererFactory {
     public static final String EXTENSION = "txt";
     private PrintWriter output;
-    
+
     public Renderer createRenderer() { 
         return new PlainTextRenderer();
     }
@@ -122,13 +122,10 @@ public class PlainTextRenderer extends RunListener 
implements Renderer, Renderer
     }
 
     @Override
-    public void testRunStarted(Description description)
-            throws Exception {
-        super.testRunStarted(description);
-    }
-
-    @Override
-    public void testStarted(Description description) throws Exception {
-        super.testStarted(description);
+    public void testSuiteStarted(Description description) throws Exception {
+        super.testSuiteStarted(description);
+        if (description.getTestClass() != null) {
+            title(3, description.getClassName());
+        }
     }
 }
diff --git 
a/src/main/java/org/apache/sling/junit/impl/servlet/ServletProcessor.java 
b/src/main/java/org/apache/sling/junit/impl/servlet/ServletProcessor.java
index adc3ce3..170ae60 100644
--- a/src/main/java/org/apache/sling/junit/impl/servlet/ServletProcessor.java
+++ b/src/main/java/org/apache/sling/junit/impl/servlet/ServletProcessor.java
@@ -19,9 +19,7 @@ package org.apache.sling.junit.impl.servlet;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.util.Collections;
-import java.util.LinkedList;
-import java.util.List;
+import java.util.Collection;
 
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
@@ -52,19 +50,6 @@ public class ServletProcessor {
         this.rendererSelector = rendererSelector;
     }
 
-    /** Return sorted list of available tests
-     * @param prefix optionally select only names that match this prefix
-     */
-    private List<String> getTestNames(TestSelector selector, boolean 
forceReload) {
-        final List<String> result = new LinkedList<String>();
-        if(forceReload) {
-            log.debug("{} is true, clearing TestsManager caches", 
FORCE_RELOAD_PARAM);
-        }
-        result.addAll(testsManager.getTestNames(selector));
-        Collections.sort(result);
-        return result;
-    }
-
     private void sendCss(HttpServletResponse response) throws IOException {
         final InputStream str = getClass().getResourceAsStream("/" + CSS);
         if(str == null) {
@@ -81,16 +66,17 @@ public class ServletProcessor {
         }
     }
 
-    private boolean getForceReloadOption(HttpServletRequest request) {
-        final boolean forceReload = 
"true".equalsIgnoreCase(request.getParameter(FORCE_RELOAD_PARAM));
-        log.debug("{} option is set to {}", FORCE_RELOAD_PARAM, forceReload);
-        return forceReload;
+    private void logForceReloadOptionDeprecation(HttpServletRequest request) {
+        final String forceReloadParam = 
request.getParameter(FORCE_RELOAD_PARAM);
+        if (forceReloadParam != null) {
+            log.info("{} option is no longer necessary and its use is 
therefore deprecated", FORCE_RELOAD_PARAM);
+        }
     }
 
     /** GET request lists available tests */
     public void doGet(final HttpServletRequest request, final 
HttpServletResponse response, final String servletPath)
     throws ServletException, IOException {
-        final boolean forceReload = getForceReloadOption(request);
+        logForceReloadOptionDeprecation(request);
 
         // Redirect to / if called without it, and serve CSS if requested
         {
@@ -104,8 +90,8 @@ public class ServletProcessor {
         }
 
         final TestSelector selector = getTestSelector(request);
-        final List<String> testNames = getTestNames(selector, forceReload);
-        
+        final Collection<String> testNames = 
testsManager.getTestNames(selector);
+
         // 404 if no tests found
         if(testNames.isEmpty()) {
             final String msg = 
@@ -137,10 +123,10 @@ public class ServletProcessor {
     /** POST request executes tests */
     public void doPost(HttpServletRequest request, HttpServletResponse 
response)
     throws ServletException, IOException {
+        logForceReloadOptionDeprecation(request);
+
         final TestSelector selector = getTestSelector(request);
-        final boolean forceReload = getForceReloadOption(request);
-        log.info("POST request, executing tests: {}, {}={}",
-                new Object[] { selector, FORCE_RELOAD_PARAM, forceReload});
+        log.info("POST request, executing tests: {}", selector);
 
         final Renderer renderer = rendererSelector.getRenderer(selector);
         if(renderer == null) {
@@ -148,15 +134,14 @@ public class ServletProcessor {
         }
         renderer.setup(response, getClass().getSimpleName());
 
-        final List<String> testNames = getTestNames(selector, forceReload);
-        if(testNames.isEmpty()) {
+        try {
+            testsManager.executeTests(renderer, selector);
+        } catch(TestsManager.NoTestCasesFoundException e) {
             response.sendError(
                     HttpServletResponse.SC_NOT_FOUND,
                     "No tests found for " + selector);
-        }
-        try {
-            testsManager.executeTests(testNames, renderer, selector);
-        } catch(Exception e) {
+            return;
+        } catch (Exception e) {
             throw new ServletException(e);
         }
 
diff --git a/src/main/java/org/apache/sling/junit/impl/servlet/XmlRenderer.java 
b/src/main/java/org/apache/sling/junit/impl/servlet/XmlRenderer.java
index 50a297b..52eaa4e 100644
--- a/src/main/java/org/apache/sling/junit/impl/servlet/XmlRenderer.java
+++ b/src/main/java/org/apache/sling/junit/impl/servlet/XmlRenderer.java
@@ -250,6 +250,14 @@ public class XmlRenderer extends RunListener implements 
Renderer, RendererFactor
        }
 
        @Override
+       public void testSuiteStarted(Description description) throws Exception {
+               super.testSuiteStarted(description);
+               if (description.getTestClass() != null) {
+                       title(3, description.getClassName());
+               }
+       }
+
+       @Override
        public void testStarted(Description description) throws Exception {
                super.testStarted(description);
                tests.put(description, new Long(System.currentTimeMillis()));
diff --git 
a/src/main/java/org/apache/sling/junit/impl/servlet/junit5/DescriptionGenerator.java
 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/DescriptionGenerator.java
new file mode 100644
index 0000000..bf3a56b
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/DescriptionGenerator.java
@@ -0,0 +1,65 @@
+/*
+ * 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.junit.platform.engine.TestSource;
+import org.junit.platform.engine.support.descriptor.ClassSource;
+import org.junit.platform.engine.support.descriptor.MethodSource;
+import org.junit.platform.launcher.TestIdentifier;
+import org.junit.runner.Description;
+
+import java.util.Arrays;
+import java.util.Optional;
+import java.util.function.Function;
+
+public enum DescriptionGenerator {
+
+    CLASS_SOURCE(ClassSource.class, src -> 
Description.createSuiteDescription(src.getJavaClass())),
+    
+    METHOD_SOURCE(MethodSource.class, src -> 
Description.createTestDescription(src.getClassName(), src.getMethodName()))
+
+    ;
+
+    private final Class<? extends TestSource> clazz;
+
+    private final Function<? super TestSource, Description> generator;
+
+    @SuppressWarnings("unchecked")
+    <T extends TestSource> DescriptionGenerator(Class<T> clazz, Function<? 
super T, Description> generator) {
+        this.clazz = clazz;
+        this.generator = (Function<? super TestSource, Description>) generator;
+    }
+
+    @NotNull
+    public static Optional<Description> toDescription(TestIdentifier 
testIdentifier) {
+        return 
testIdentifier.getSource().map(DescriptionGenerator::createDescription);
+    }
+
+    static Description createDescription(TestSource testSource) {
+        if (testSource != null) {
+            return Arrays.stream(values())
+                    .filter(v -> v.clazz.isInstance(testSource))
+                    .map(v -> v.generator.apply(testSource))
+                    .findFirst()
+                    .orElse(null);
+        }
+        return null;
+    }
+}
diff --git 
a/src/main/java/org/apache/sling/junit/impl/servlet/junit5/FailureHelper.java 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/FailureHelper.java
new file mode 100644
index 0000000..e562e91
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/FailureHelper.java
@@ -0,0 +1,44 @@
+/*
+ * 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.launcher.TestIdentifier;
+import org.junit.platform.launcher.listeners.TestExecutionSummary;
+import org.junit.runner.notification.Failure;
+
+import static 
org.apache.sling.junit.impl.servlet.junit5.DescriptionGenerator.toDescription;
+
+public final class FailureHelper {
+
+    @Nullable
+    public static Failure convert(TestIdentifier testIdentifier, Throwable 
throwable) {
+        return toDescription(testIdentifier)
+                .map(d -> new Failure(d, throwable))
+                .orElse(null);
+    }
+
+    @Nullable
+    public static Failure convert(@NotNull TestExecutionSummary.Failure f) {
+        return convert(f.getTestIdentifier(), f.getException());
+    }
+
+    private FailureHelper() {}
+}
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
new file mode 100644
index 0000000..89a6a1e
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnit5TestExecutionStrategy.java
@@ -0,0 +1,84 @@
+/*
+ * 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.apache.sling.junit.TestSelector;
+import org.apache.sling.junit.impl.TestExecutionStrategy;
+import org.apache.sling.junit.impl.TestsManagerImpl;
+import org.junit.platform.engine.DiscoverySelector;
+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.stream.Stream;
+
+import static 
org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod;
+
+public class JUnit5TestExecutionStrategy implements TestExecutionStrategy {
+
+    private final TestsManagerImpl testsManager;
+
+    private final TestEngineTracker testEngineTracker;
+
+    public JUnit5TestExecutionStrategy(TestsManagerImpl testsManager, 
BundleContext ctx) {
+        this.testsManager = testsManager;
+        testEngineTracker = new TestEngineTracker(ctx);
+    }
+
+    @Override
+    public void close() {
+        testEngineTracker.close();
+    }
+
+    @Override
+    public void execute(TestSelector selector, RunListener runListener) throws 
Exception {
+        Launcher launcher = LauncherFactory.create(
+                LauncherConfig.builder()
+                        
.addTestEngines(testEngineTracker.getAvailableTestEngines())
+                        .addTestExecutionListeners(new 
RunListenerAdapter(runListener))
+                        .enableTestEngineAutoRegistration(false)
+                        .enableTestExecutionListenerAutoRegistration(false)
+                        .build()
+        );
+
+        final LauncherDiscoveryRequest request =
+                testsManager.createTestRequest(selector, this::methodRequest, 
this::classesRequest);
+        launcher.execute(request);
+    }
+
+    private LauncherDiscoveryRequest methodRequest(Class<?> testClass, String 
testMethodName) {
+        return LauncherDiscoveryRequestBuilder.request()
+                .selectors(selectMethod(testClass, testMethodName))
+                .build();
+    }
+
+    private LauncherDiscoveryRequest classesRequest(Class<?>[] testClasses) {
+        final DiscoverySelector[] selectors = Stream.of(testClasses)
+                .map(DiscoverySelectors::selectClass)
+                .toArray(DiscoverySelector[]::new);
+        return LauncherDiscoveryRequestBuilder.request()
+                .selectors(selectors)
+                .build();
+    }
+}
diff --git 
a/src/main/java/org/apache/sling/junit/impl/servlet/junit5/ResultAdapter.java 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/ResultAdapter.java
new file mode 100644
index 0000000..8119527
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/ResultAdapter.java
@@ -0,0 +1,80 @@
+/*
+ * 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.junit.platform.launcher.listeners.TestExecutionSummary;
+import org.junit.runner.Result;
+import org.junit.runner.notification.Failure;
+import org.junit.runner.notification.RunListener;
+
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+public class ResultAdapter extends Result {
+    
+    private final transient TestExecutionSummary summary;
+
+    public ResultAdapter(TestExecutionSummary summary) {
+        this.summary = summary;
+    }
+
+    @Override
+    public int getRunCount() {
+        return (int) summary.getTestsStartedCount();
+    }
+
+    @Override
+    public int getFailureCount() {
+        return (int) summary.getTestsFailedCount();
+    }
+
+    @Override
+    public long getRunTime() {
+        return summary.getTimeFinished() - summary.getTimeStarted();
+    }
+
+    @Override
+    public List<Failure> getFailures() {
+        return summary.getFailures().stream()
+                .map(FailureHelper::convert)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public int getIgnoreCount() {
+        return (int) summary.getTestsSkippedCount();
+    }
+
+    @Override
+    public int getAssumptionFailureCount() {
+        return 0;
+    }
+
+    @Override
+    public boolean wasSuccessful() {
+        return summary.getTestsFailedCount() == 0;
+    }
+
+    @Override
+    public RunListener createListener() {
+        throw new UnsupportedOperationException("createListener is not 
implemented");
+    }
+}
diff --git 
a/src/main/java/org/apache/sling/junit/impl/servlet/junit5/RunListenerAdapter.java
 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/RunListenerAdapter.java
new file mode 100644
index 0000000..67c73fc
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/RunListenerAdapter.java
@@ -0,0 +1,136 @@
+/*
+ * 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.junit.platform.engine.TestExecutionResult;
+import org.junit.platform.engine.reporting.ReportEntry;
+import org.junit.platform.launcher.TestExecutionListener;
+import org.junit.platform.launcher.TestIdentifier;
+import org.junit.platform.launcher.TestPlan;
+import org.junit.platform.launcher.listeners.SummaryGeneratingListener;
+import org.junit.platform.launcher.listeners.TestExecutionSummary;
+import org.junit.runner.Description;
+import org.junit.runner.Result;
+import org.junit.runner.notification.RunListener;
+
+import java.util.function.Consumer;
+
+import static 
org.apache.sling.junit.impl.servlet.junit5.DescriptionGenerator.toDescription;
+
+public class RunListenerAdapter implements TestExecutionListener {
+
+    private final RunListener runListener;
+    
+    private final SummaryGeneratingListener summarizer;
+
+    public RunListenerAdapter(RunListener runListener) {
+        this.runListener = runListener;
+        this.summarizer = new SummaryGeneratingListener();
+    }
+
+    @Override
+    public void testPlanExecutionStarted(TestPlan testPlan) {
+        summarizer.testPlanExecutionStarted(testPlan);
+        try {
+            
runListener.testRunStarted(Description.createSuiteDescription("classes"));
+        } catch (Exception exception) {
+            throw new RuntimeException(exception);
+        }
+    }
+
+    @Override
+    public void testPlanExecutionFinished(TestPlan testPlan) {
+        summarizer.testPlanExecutionFinished(testPlan);
+
+        final TestExecutionSummary summary = summarizer.getSummary();
+
+        final Result result = new ResultAdapter(summary);
+
+        try {
+            runListener.testRunFinished(result);
+        } catch (Exception exception) {
+            throw new RuntimeException(exception);
+        }
+    }
+
+    @Override
+    public void executionStarted(TestIdentifier testIdentifier) {
+        summarizer.executionStarted(testIdentifier);
+        if (testIdentifier.isTest()) {
+            withDescription(testIdentifier, runListener::testStarted);
+        } else {
+            withDescription(testIdentifier, runListener::testSuiteStarted);
+        }
+    }
+
+    @Override
+    public void executionSkipped(TestIdentifier testIdentifier, String reason) 
{
+        summarizer.executionSkipped(testIdentifier, reason);
+        if (testIdentifier.isTest()) {
+            withDescription(testIdentifier, runListener::testIgnored);
+        }
+    }
+
+    @Override
+    public void executionFinished(TestIdentifier testIdentifier, 
TestExecutionResult testExecutionResult) {
+        summarizer.executionFinished(testIdentifier, testExecutionResult);
+        if (testIdentifier.isTest()) {
+            if (testExecutionResult.getStatus() != 
TestExecutionResult.Status.SUCCESSFUL) {
+                try {
+                    
runListener.testFailure(FailureHelper.convert(testIdentifier, 
testExecutionResult.getThrowable().orElse(null)));
+                } catch (Exception exception) {
+                    throw new RuntimeException(exception);
+                }
+            }
+            withDescription(testIdentifier, runListener::testFinished);
+        } else {
+            withDescription(testIdentifier, runListener::testSuiteFinished);
+
+        }
+    }
+
+    @Override
+    public void dynamicTestRegistered(TestIdentifier testIdentifier) {
+        summarizer.dynamicTestRegistered(testIdentifier);
+    }
+
+    @Override
+    public void reportingEntryPublished(TestIdentifier testIdentifier, 
ReportEntry entry) {
+        summarizer.reportingEntryPublished(testIdentifier, entry);
+    }
+
+    private static void withDescription(TestIdentifier testIdentifier, 
ExceptionHandlingConsumer<Description, Exception> action) {
+        toDescription(testIdentifier).ifPresent(action);
+    }
+
+    private interface ExceptionHandlingConsumer<S, E extends Exception> 
extends Consumer<S> {
+        @Override
+        default void accept(S s) {
+            try {
+                acceptAndThrow(s);
+            } catch (RuntimeException e) {
+                throw e;
+            } catch (Exception exception) {
+                throw new RuntimeException(exception);
+            }
+        }
+
+        void acceptAndThrow(S s) throws E;
+    }
+}
diff --git 
a/src/main/java/org/apache/sling/junit/impl/servlet/junit5/TestEngineTracker.java
 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/TestEngineTracker.java
new file mode 100644
index 0000000..c2ddad6
--- /dev/null
+++ 
b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/TestEngineTracker.java
@@ -0,0 +1,92 @@
+/*
+ * 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.junit.platform.engine.TestEngine;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.BundleEvent;
+import org.osgi.framework.wiring.BundleWiring;
+import org.osgi.util.tracker.BundleTracker;
+import org.osgi.util.tracker.BundleTrackerCustomizer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.Closeable;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class TestEngineTracker implements Closeable {
+
+    private static final Logger LOG = 
LoggerFactory.getLogger(TestEngineTracker.class);
+    
+    private final BundleTracker<AtomicReference<Set<TestEngine>>> tracker;
+
+    public TestEngineTracker(BundleContext bundleContext) {
+        tracker = new BundleTracker<>(bundleContext, Bundle.ACTIVE, new 
Customizer());
+        tracker.open();
+    }
+
+    public TestEngine[] getAvailableTestEngines() {
+        return tracker.getTracked().values().stream()
+                .map(AtomicReference::get)
+                .flatMap(Collection::stream)
+                .toArray(TestEngine[]::new);
+    }
+
+    @Override
+    public void close() {
+        tracker.close();
+    }
+
+    private static class Customizer implements 
BundleTrackerCustomizer<AtomicReference<Set<TestEngine>>> {
+
+        @Override
+        public AtomicReference<Set<TestEngine>> addingBundle(Bundle bundle, 
BundleEvent event) {
+            return new AtomicReference<>(getTestEnginesForBundle(bundle));
+        }
+
+        @Override
+        public void modifiedBundle(Bundle bundle, BundleEvent event, 
AtomicReference<Set<TestEngine>> testEngines) {
+            testEngines.set(getTestEnginesForBundle(bundle));
+        }
+
+        @Override
+        public void removedBundle(Bundle bundle, BundleEvent event, 
AtomicReference<Set<TestEngine>> testEngines) {
+            testEngines.set(Collections.emptySet());
+        }
+
+        @NotNull
+        private static Set<TestEngine> getTestEnginesForBundle(Bundle bundle) {
+            final Set<TestEngine> testEngines = new HashSet<>();
+            ServiceLoader
+                    .load(TestEngine.class, 
bundle.adapt(BundleWiring.class).getClassLoader())
+                    .forEach(testEngine -> {
+                        LOG.info("Found TestEngine '{}' in bundle '{}'", 
testEngine.getId(), bundle.getSymbolicName());
+                        testEngines.add(testEngine);
+                    });
+            return testEngines;
+        }
+    }
+}
diff --git a/src/main/java/org/apache/sling/junit/package-info.java 
b/src/main/java/org/apache/sling/junit/package-info.java
index 374ef48..64790d9 100644
--- a/src/main/java/org/apache/sling/junit/package-info.java
+++ b/src/main/java/org/apache/sling/junit/package-info.java
@@ -16,7 +16,7 @@
  ~ specific language governing permissions and limitations
  ~ under the License.
  
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/
-@Version("1.1.1")
+@Version("1.2.0")
 package org.apache.sling.junit;
 
 import org.osgi.annotation.versioning.Version;
diff --git a/src/main/resources/junit.css b/src/main/resources/junit.css
index 74c6987..9986af3 100644
--- a/src/main/resources/junit.css
+++ b/src/main/resources/junit.css
@@ -43,6 +43,9 @@ h2,h3,h4 {
   padding: 1em;
   border: solid red 1px;
   background-color: #FFFFCC;
+  white-space: pre;
+  max-height: 20.5em;
+  overflow-y: scroll;
 }
 
 .failure h3 {
diff --git 
a/src/test/java/org/apache/sling/junit/impl/JUnit4TestExecutionStrategyTest.java
 
b/src/test/java/org/apache/sling/junit/impl/JUnit4TestExecutionStrategyTest.java
new file mode 100644
index 0000000..d4ff5da
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/junit/impl/JUnit4TestExecutionStrategyTest.java
@@ -0,0 +1,64 @@
+/*
+ * 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;
+
+import org.apache.sling.junit.TestSelector;
+import org.apache.sling.junit.sampletests.JUnit4SlingJUnit;
+import org.junit.Test;
+import org.junit.runner.Request;
+import org.junit.runner.Result;
+import org.junit.runner.notification.RunListener;
+
+import java.util.Objects;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+public class JUnit4TestExecutionStrategyTest {
+
+    @Test
+    public void testExecution() throws Exception {
+        final Request request = Request.method(JUnit4SlingJUnit.class, 
"testSuccessful");
+        final TestsManagerImpl testsManager = mock(TestsManagerImpl.class);
+        when(testsManager.createTestRequest(any(), any(), 
any())).thenReturn(request);
+        final JUnit4TestExecutionStrategy strategy = new 
JUnit4TestExecutionStrategy(testsManager);
+        final RunListener runListener = mock(RunListener.class);
+        strategy.execute(mock(TestSelector.class), runListener);
+        verify(runListener, times(1))
+                .testRunStarted(any());
+        verify(runListener, times(1))
+                .testSuiteStarted(argThat(desc -> 
Objects.equals(desc.getClassName(), JUnit4SlingJUnit.class.getName())));
+        verify(runListener, times(1))
+                .testStarted(argThat(desc -> 
Objects.equals(desc.getMethodName(), "testSuccessful")));
+        verify(runListener, times(1))
+                .testFinished(argThat(desc -> 
Objects.equals(desc.getMethodName(), "testSuccessful")));
+        verify(runListener, times(1))
+                .testSuiteFinished(argThat(desc -> 
Objects.equals(desc.getClassName(), JUnit4SlingJUnit.class.getName())));
+        verify(runListener, times(1))
+                .testRunFinished(argThat(Result::wasSuccessful));
+        verifyNoMoreInteractions(runListener);
+        
+        strategy.close();
+    }
+}
\ No newline at end of file
diff --git 
a/src/test/java/org/apache/sling/junit/impl/TestsManagerImplTest.java 
b/src/test/java/org/apache/sling/junit/impl/TestsManagerImplTest.java
index 8b394f4..9cb9ee1 100644
--- a/src/test/java/org/apache/sling/junit/impl/TestsManagerImplTest.java
+++ b/src/test/java/org/apache/sling/junit/impl/TestsManagerImplTest.java
@@ -16,98 +16,370 @@
  */
 package org.apache.sling.junit.impl;
 
-import static junit.framework.TestCase.assertFalse;
-import static junit.framework.TestCase.assertTrue;
-import static org.powermock.api.mockito.PowerMockito.mock;
-import static org.powermock.api.mockito.PowerMockito.when;
+import net.bytebuddy.ByteBuddy;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.dynamic.scaffold.InstrumentedType;
+import net.bytebuddy.implementation.DefaultMethodCall;
+import net.bytebuddy.implementation.Implementation;
+import net.bytebuddy.implementation.MethodCall;
+import net.bytebuddy.implementation.bytecode.ByteCodeAppender;
+import net.bytebuddy.matcher.ElementMatchers;
+import org.apache.sling.junit.Renderer;
+import org.apache.sling.junit.RequestParser;
+import org.apache.sling.junit.TestsManager;
+import org.apache.sling.junit.TestsProvider;
+import org.apache.sling.junit.impl.servlet.PlainTextRenderer;
+import org.apache.sling.junit.sampletests.JUnit4SlingJUnit;
+import org.hamcrest.Matchers;
+import org.jetbrains.annotations.NotNull;
+import org.junit.Test;
+import org.junit.runner.notification.RunListener;
+import org.junit.vintage.engine.VintageTestEngine;
+import org.osgi.framework.Bundle;
+import org.osgi.framework.BundleContext;
+import org.osgi.framework.Constants;
+import org.osgi.framework.InvalidSyntaxException;
+import org.osgi.framework.ServiceReference;
+import org.osgi.framework.wiring.BundleWiring;
 
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Dictionary;
+import java.util.Enumeration;
 import java.util.HashSet;
 import java.util.Hashtable;
+import java.util.List;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
-import org.apache.sling.junit.Activator;
-import org.junit.Test;
-import org.junit.runner.RunWith;
-import org.mockito.Mockito;
-import org.osgi.framework.Bundle;
-import org.osgi.framework.BundleContext;
-import org.powermock.api.mockito.PowerMockito;
-import org.powermock.core.classloader.annotations.PrepareForTest;
-import org.powermock.modules.junit4.PowerMockRunner;
-import org.powermock.reflect.Whitebox;
+import java.util.function.BiConsumer;
+import java.util.stream.Collectors;
+
+import static java.util.Arrays.asList;
+import static java.util.Collections.emptyList;
+import static java.util.Collections.emptySet;
+import static java.util.Collections.singletonList;
+import static junit.framework.TestCase.assertFalse;
+import static junit.framework.TestCase.assertTrue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.nullValue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 /**
  * Validate waitForSystemStartup method, along with private some 
implementations.
  */
-@RunWith(PowerMockRunner.class)
-@PrepareForTest({ Activator.class, TestsManagerImpl.class })
 public class TestsManagerImplTest {
 
-  private static final String WAIT_METHOD_NAME = "needToWait";
-  private static final int SYSTEM_STARTUP_SECONDS = 2;
-
-  static {
-    // Set a short timeout so our tests can run faster
-    System.setProperty("sling.junit.core.SystemStartupTimeoutSeconds", 
String.valueOf(SYSTEM_STARTUP_SECONDS));
-  }
-
-  /**
-   * case if needToWait should return true, mainly it still have some bundles 
in the list to wait, and global timeout didn't pass.
-   */
-  @Test
-  public void needToWaitPositiveNotEmptyListNotGloballyTimeout() throws 
Exception {
-    long startupTimeout = System.currentTimeMillis() + 
TimeUnit.SECONDS.toMillis(5 * SYSTEM_STARTUP_SECONDS);
-    final Set<Bundle> bundlesToWaitFor = new HashSet<Bundle>();
-    bundlesToWaitFor.add(Mockito.mock(Bundle.class));
-    assertTrue((Boolean)Whitebox.invokeMethod(TestsManagerImpl.class, 
WAIT_METHOD_NAME, startupTimeout, bundlesToWaitFor));
-  }
-
-  /**
-   * case if needToWait should return false, when for example it reached the 
global timeout limit.
-   */
-  @Test
-  public void needToWaitNegativeForstartupTimeout() throws Exception {
-    long lastChange = System.currentTimeMillis() - 
TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS / 2);
-    long startupTimeout = lastChange - TimeUnit.SECONDS.toMillis(1);
-    assertFalse((Boolean)Whitebox.invokeMethod(TestsManagerImpl.class, 
WAIT_METHOD_NAME, startupTimeout, new HashSet<Bundle>()));
-  }
-
-  /**
-   * case if needToWait should return false, when for example it reached the 
global timeout limit.
-   */
-  @Test
-  public void needToWaitNegativeForEmptyList() throws Exception {
-    long lastChange = System.currentTimeMillis() - 
TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS / 2);
-    long startupTimeout = lastChange + TimeUnit.SECONDS.toMillis(10);
-    assertFalse((Boolean)Whitebox.invokeMethod(TestsManagerImpl.class, 
WAIT_METHOD_NAME, startupTimeout, new HashSet<Bundle>()));
-  }
-
-  @Test
-  public void waitForSystemStartupTimeout() {
-    setupBundleContextMock(Bundle.INSTALLED);
-    final long elapsed = TestsManagerImpl.waitForSystemStartup();
-    assertTrue(elapsed > TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS));
-    assertTrue(elapsed < TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS + 
1));
-    assertFalse((Boolean) Whitebox.getInternalState(TestsManagerImpl.class, 
"waitForSystemStartup"));
-  }
-
-  @Test
-  public void waitForSystemStartupAllActiveBundles() {
-    setupBundleContextMock(Bundle.ACTIVE);
-    final long elapsed = TestsManagerImpl.waitForSystemStartup();
-    assertTrue(elapsed < TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS));
-    assertFalse((Boolean) Whitebox.getInternalState(TestsManagerImpl.class, 
"waitForSystemStartup"));
-  }
-
-  private void setupBundleContextMock(final int bundleState) {
-    PowerMockito.mockStatic(Activator.class);
-    BundleContext mockedBundleContext = mock(BundleContext.class);
-    Bundle mockedBundle = mock(Bundle.class);
-    Hashtable<String, String> bundleHeaders = new Hashtable<String, String>();
-    when(mockedBundle.getState()).thenReturn(bundleState);
-    when(mockedBundle.getHeaders()).thenReturn(bundleHeaders);
-    when(mockedBundleContext.getBundles()).thenReturn(new Bundle[] { 
mockedBundle });
-    when(Activator.getBundleContext()).thenReturn(mockedBundleContext);
-    Whitebox.setInternalState(TestsManagerImpl.class, "waitForSystemStartup", 
true);
-  }
+    private static final int SYSTEM_STARTUP_SECONDS = 2;
+
+    private Set<Bundle> mockBundles = new HashSet<>();
+
+    static {
+        // Set a short timeout so our tests can run faster
+        System.setProperty("sling.junit.core.SystemStartupTimeoutSeconds", 
String.valueOf(SYSTEM_STARTUP_SECONDS));
+    }
+
+    /**
+     * case if needToWait should return true, mainly it still have some 
bundles in the list to wait, and global timeout didn't pass.
+     */
+    @Test
+    public void needToWaitPositiveNotEmptyListNotGloballyTimeout() {
+        long startupTimeout = System.currentTimeMillis() + 
TimeUnit.SECONDS.toMillis(5 * SYSTEM_STARTUP_SECONDS);
+        final Set<Bundle> bundlesToWaitFor = new 
HashSet<>(singletonList(mock(Bundle.class)));
+        assertTrue(TestsManagerImpl.needToWait(startupTimeout, 
bundlesToWaitFor));
+    }
+
+    /**
+     * case if needToWait should return false, when for example it reached the 
global timeout limit.
+     */
+    @Test
+    public void needToWaitNegativeForstartupTimeout() {
+        long lastChange = System.currentTimeMillis() - 
TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS / 2);
+        long startupTimeout = lastChange - TimeUnit.SECONDS.toMillis(1);
+        assertFalse(TestsManagerImpl.needToWait(startupTimeout, emptySet()));
+    }
+
+    /**
+     * case if needToWait should return false, when for example it reached the 
global timeout limit.
+     */
+    @Test
+    public void needToWaitNegativeForEmptyList() {
+        long lastChange = System.currentTimeMillis() - 
TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS / 2);
+        long startupTimeout = lastChange + TimeUnit.SECONDS.toMillis(10);
+        assertFalse(TestsManagerImpl.needToWait(startupTimeout, emptySet()));
+    }
+
+    @Test
+    public void waitForSystemStartupTimeout() {
+        BundleContext bundleContext = setupBundleContext(Bundle.INSTALLED);
+        TestsManagerImpl testsManager = new TestsManagerImpl();
+        testsManager.activate(bundleContext);
+
+        assertFalse(testsManager.isReady());
+
+        final long elapsed = testsManager.waitForSystemStartup();
+        assertTrue(elapsed > 
TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS));
+        assertTrue(elapsed < TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS 
+ 1));
+        assertTrue(testsManager.isReady());
+
+        // second call is instantaneous
+        assertTrue(10 > testsManager.waitForSystemStartup());
+
+        testsManager.deactivate();
+    }
+
+    @Test
+    public void waitForSystemStartupAllActiveBundles() {
+        BundleContext bundleContext = setupBundleContext(Bundle.ACTIVE);
+        TestsManagerImpl testsManager = new TestsManagerImpl();
+        testsManager.activate(bundleContext);
+
+        assertFalse(testsManager.isReady());
+
+        final long elapsed = testsManager.waitForSystemStartup();
+        assertTrue(elapsed < 
TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS));
+        assertTrue(testsManager.isReady());
+
+        testsManager.deactivate();
+    }
+
+    @Test
+    public void testDeactivateBeforeActivateIgnored() {
+        try {
+            new TestsManagerImpl().deactivate();
+        } catch (Exception e) {
+            fail("deactivate before activate should be a no-op");
+        }
+    }
+
+    private BundleContext setupBundleContext(int state) {
+        final Bundle bundle = mock(Bundle.class);
+        when(bundle.getSymbolicName()).thenReturn("mocked-bundle");
+        when(bundle.getState()).thenReturn(state);
+        
when(bundle.adapt(BundleWiring.class)).thenReturn(mock(BundleWiring.class));
+        when(bundle.getHeaders()).thenReturn(new Hashtable<>());
+
+        final BundleContext bundleContext = mock(BundleContext.class);
+        when(bundleContext.getBundle()).thenReturn(bundle);
+        when(bundleContext.getBundles()).thenAnswer(m -> new Bundle[]{bundle});
+
+        when(bundle.getBundleContext()).thenReturn(bundleContext);
+        return bundleContext;
+    }
+
+
+    @Test
+    public void testGettingTestNamesAndClassesAndExecution() throws Exception {
+
+        final ArrayList<String> allTestClasses = new ArrayList<>();
+
+        for (int i = 0; i < 5; i++) {
+
+            final List<String> testClasses = asList(
+                    "org.apache.sling.junit.testbundle" + i + ".ASlingJUnit",
+                    "org.apache.sling.junit.testbundle" + i + 
".impl.ANestedSlingJUnit"
+            );
+
+            allTestClasses.addAll(testClasses);
+
+            final List<String> nonTestClasses = asList(
+                    "org.apache.sling.junit.testbundle" + i + ".NotATest",
+                    "org.apache.sling.junit.testbundle" + i + 
".impl.AlsoNotATest",
+                    "org.apache.sling.junit.testbundle" + i + 
".CompletelyUnrelated"
+            );
+
+            final List<String> classes = new ArrayList<>();
+            classes.addAll(testClasses);
+            classes.addAll(nonTestClasses);
+            classes.sort(Comparator.naturalOrder());
+
+            createTestBundle(
+                    "test-bundle-" + i,
+                    "org.apache.sling.junit.testbundle" + i + ".*SlingJUnit",
+                    classes
+            );
+        }
+
+        createTestBundle("test-bundle-no-tests", 
"org.apache.sling.junit.notests.*SlingJUnit", emptyList());
+        createTestBundle("test-bundle-invalid-regexp", "[a-z", emptyList());
+        createTestBundle("test-bundle-no-regexp", null, emptyList());
+        createFragmentBundle("fragment");
+
+        final Bundle junitBundle = createMockBundle("junit-bundle", 
Bundle.ACTIVE);
+        addBundleWiring(junitBundle, VintageTestEngine.class.getClassLoader());
+        final BundleContext bundleContext = junitBundle.getBundleContext();
+        final BundleTestsProvider bundleTestsProvider =
+                activateAndRegister(bundleContext, TestsProvider.class, new 
BundleTestsProvider(), BundleTestsProvider::activate);
+        final TestsManagerImpl testsManager =
+                activateAndRegister(bundleContext, TestsManager.class, new 
TestsManagerImpl(), TestsManagerImpl::activate);
+
+        final RequestParser selector = new RequestParser(null);
+        final Collection<String> testNames = 
testsManager.getTestNames(selector);
+
+        assertThat("should find all tests", testNames, 
Matchers.containsInAnyOrder(allTestClasses.toArray(new String[0])));
+
+        for (String testName : testNames) {
+            assertThat("should be able to load class " + testName, 
testsManager.getTestClass(testName), Matchers.isA(Class.class));
+        }
+
+        try {
+            testsManager.getTestClass("a.class.that.does.not.Exist");
+            fail("should not load non-existant test class");
+        } catch (ClassNotFoundException e) {
+            // expected
+        }
+
+        testsManager.executeTests(createRenderer(), null);
+        testsManager.executeTests(createRenderer(), new 
RequestParser("org.apache.sling.junit.testbundle0.ASlingJUnit/.html"));
+        testsManager.executeTests(createRenderer(), new 
RequestParser("org.apache.sling.junit.testbundle0.ASlingJUnit/testSuccessful.html"));
+
+        {
+            final Renderer renderer = createRenderer();
+            final RequestParser requestParser = new 
RequestParser("org.apache.sling.junit.testbundle0/testSuccessful.html");
+            try {
+                testsManager.executeTests(renderer, requestParser);
+                fail("IllegalStateException expected when selecting method for 
multiple tests");
+            } catch (IllegalStateException e) {
+                // expected
+            }
+        }
+
+        {
+            final Renderer renderer = createRenderer();
+            final RequestParser requestParser = new 
RequestParser("no.test.Available.html");
+            try {
+                testsManager.executeTests(renderer, requestParser);
+                fail("NoTestCasesFoundException expected when selecting 
non-existing test class");
+            } catch (TestsManager.NoTestCasesFoundException e) {
+                // expected
+            }
+        }
+
+        testsManager.deactivate();
+        bundleTestsProvider.deactivate();
+    }
+
+    public void createFragmentBundle(String symbolicName) throws IOException {
+        final Bundle bundle = createMockBundle(symbolicName, Bundle.ACTIVE);
+
+        final Dictionary<String, String> fragmentHeaders = bundle.getHeaders();
+        fragmentHeaders.put(BundleTestsProvider.SLING_TEST_REGEXP, 
"system.bundle.*SlingJUnit");
+        fragmentHeaders.put(Constants.FRAGMENT_HOST, "system.bundle");
+
+        when(bundle.findEntries("", "*.class", true))
+                .thenAnswer(m -> 
classesAsResourceEnumeration(singletonList("system.bundle.FragmentSlingJUnit")));
+
+        addBundleWiring(bundle, emptyMockClassloader());
+    }
+
+    private <T> T activateAndRegister(BundleContext bundleContext, Class<? 
super T> interfaze, T service, BiConsumer<T, BundleContext> activator)
+            throws InvalidSyntaxException {
+        activator.accept(service, bundleContext);
+        registerService(bundleContext, service, interfaze);
+        return service;
+    }
+
+    private static Renderer createRenderer() throws IOException {
+        final PlainTextRenderer renderer = new PlainTextRenderer() {
+            @Override
+            public RunListener getRunListener() {
+                return super.getRunListener();
+            }
+        };
+        final HttpServletResponse response = mock(HttpServletResponse.class);
+        when(response.getWriter()).thenReturn(mock(PrintWriter.class));
+        renderer.setup(response, "Test");
+        return renderer;
+    }
+
+    @NotNull
+    private Bundle createMockBundle(String symbolicName, int state) {
+        final Bundle bundle = mock(Bundle.class);
+        when(bundle.getSymbolicName()).thenReturn(symbolicName);
+        when(bundle.getState()).thenReturn(state);
+        when(bundle.getHeaders()).thenReturn(new Hashtable<>());
+
+        final BundleContext bundleContext = mock(BundleContext.class);
+        when(bundleContext.getBundle()).thenReturn(bundle);
+
+        when(bundle.getBundleContext()).thenReturn(bundleContext);
+
+        when(bundleContext.getBundles())
+                .thenAnswer(m -> mockBundles.toArray(new Bundle[0]));
+
+        mockBundles.add(bundle);
+
+        return bundle;
+    }
+
+    private void createTestBundle(String symbolicName, String testRegexp, 
Collection<String> classes)
+            throws ClassNotFoundException, IOException {
+        final Bundle bundle = createMockBundle(symbolicName, Bundle.ACTIVE);
+
+        when(bundle.findEntries("", "*.class", true)).thenAnswer(m -> 
classesAsResourceEnumeration(classes));
+
+        // we just return the Object.class instead of a real class - we're not 
doing anything with it
+        when(bundle.loadClass(argThat(classes::contains))).then(m -> 
JUnit4SlingJUnit.class);
+        assertThat("cannot load just any class", 
bundle.loadClass("any.class.Name"), nullValue());
+
+        if (testRegexp != null) {
+            bundle.getHeaders().put(BundleTestsProvider.SLING_TEST_REGEXP, 
testRegexp);
+        }
+
+        addBundleWiring(bundle, emptyMockClassloader());
+    }
+
+    @NotNull
+    private static ClassLoader emptyMockClassloader() throws IOException {
+        final ClassLoader classLoader = mock(ClassLoader.class);
+        when(classLoader.getResources(any())).thenAnswer(m -> 
Collections.emptyEnumeration());
+        return classLoader;
+    }
+
+    private static <T> void registerService(BundleContext bundleContext, T 
service, Class<? super T> interfaze) throws InvalidSyntaxException {
+        @SuppressWarnings("unchecked") final ServiceReference<T> 
serviceReference = (ServiceReference<T>) mock(ServiceReference.class);
+        final Set<ServiceReference<T>> references = 
Collections.singleton(serviceReference);
+        when(bundleContext.getServiceReferences(interfaze, null)).thenAnswer(m 
-> references);
+        when(bundleContext.getServiceReferences(interfaze.getName(), null))
+                .thenAnswer(m -> references.toArray(new ServiceReference[0]));
+        when(bundleContext.getServiceReference(interfaze)).thenAnswer(m -> 
serviceReference);
+        
when(bundleContext.getServiceReference(interfaze.getName())).thenAnswer(m -> 
serviceReference);
+        when(bundleContext.getService(serviceReference)).thenReturn(service);
+    }
+
+    private static void addBundleWiring(Bundle bundle, ClassLoader 
classLoader) {
+        final BundleWiring bundleWiring = mock(BundleWiring.class);
+        when(bundleWiring.getClassLoader()).thenReturn(classLoader);
+        when(bundle.adapt(BundleWiring.class)).thenReturn(bundleWiring);
+    }
+
+    private static Enumeration<URL> 
classesAsResourceEnumeration(Collection<String> classes) {
+        final List<URL> resources = classes.stream()
+                .map(clazz -> '/' + clazz.replace('.', '/') + ".class")
+                .map(file -> {
+                    try {
+                        // In Apache Felix URLs look like this:
+                        //     
bundle://<random-bundle-identifier>:0/org/path/to/ClassName.class
+                        // However, the "bundle" protocol causes an exception
+                        // and in any case, only the URL's file part is used.
+                        return new URL("file://pseudo:80" + file);
+                    } catch (MalformedURLException e) {
+                        fail(e.getMessage());
+                    }
+                    // cannot be reached because "fail()" throws an 
AssertionError
+                    return null;
+                })
+                .collect(Collectors.toList());
+        return Collections.enumeration(resources);
+    }
 }
\ No newline at end of file
diff --git 
a/src/test/java/org/apache/sling/junit/impl/servlet/junit5/RunListenerAdapterTest.java
 
b/src/test/java/org/apache/sling/junit/impl/servlet/junit5/RunListenerAdapterTest.java
new file mode 100644
index 0000000..5e493f1
--- /dev/null
+++ 
b/src/test/java/org/apache/sling/junit/impl/servlet/junit5/RunListenerAdapterTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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.apache.sling.junit.sampletests.JUnit4SlingJUnit;
+import org.junit.Test;
+import org.junit.platform.engine.TestDescriptor;
+import org.junit.platform.engine.TestExecutionResult;
+import org.junit.platform.engine.UniqueId;
+import org.junit.platform.engine.support.descriptor.ClassSource;
+import org.junit.platform.engine.support.descriptor.MethodSource;
+import org.junit.platform.launcher.TestIdentifier;
+import org.junit.platform.launcher.TestPlan;
+import org.junit.runner.notification.Failure;
+import org.junit.runner.notification.RunListener;
+
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.Optional;
+
+import static java.util.Arrays.asList;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.when;
+
+public class RunListenerAdapterTest {
+
+    @Test
+    public void testLifecycle() throws Exception {
+        RunListener runListener = mock(RunListener.class);
+        RunListenerAdapter runListenerAdapter = new 
RunListenerAdapter(runListener);
+
+        // start test run
+        TestPlan testPlan = mock(TestPlan.class);
+        runListenerAdapter.testPlanExecutionStarted(testPlan);
+        verify(runListener, times(1))
+                .testRunStarted(argThat(desc -> 
desc.getClassName().equals("classes")));
+
+        // start test class (which is a suite)
+        runListenerAdapter.executionStarted(getTestIdentifierForClass());
+        verify(runListener, times(1))
+                .testSuiteStarted(argThat(desc -> 
desc.getClassName().equals(JUnit4SlingJUnit.class.getName())));
+
+        // start test method (success)
+        
runListenerAdapter.executionStarted(getTestIdentifierForMethod("testSuccessful"));
+        verify(runListener, times(1)).testStarted(
+                argThat(desc -> desc.isTest() && 
Objects.equals(desc.getMethodName(), "testSuccessful")));
+        
runListenerAdapter.executionFinished(getTestIdentifierForMethod("testSuccessful"),
 getTestExecutionResult(TestExecutionResult.Status.SUCCESSFUL));
+        verify(runListener, times(1)).testFinished(
+                argThat(desc -> desc.isTest() && 
Objects.equals(desc.getMethodName(), "testSuccessful")));
+
+        // finish test method (success)
+        
+        // start test method (failure)
+        
runListenerAdapter.executionStarted(getTestIdentifierForMethod("testFailed"));
+        verify(runListener, times(1)).testStarted(
+                argThat(desc -> desc.isTest() && 
Objects.equals(desc.getMethodName(), "testFailed")));
+        
runListenerAdapter.executionFinished(getTestIdentifierForMethod("testFailed"), 
getTestExecutionResult(TestExecutionResult.Status.FAILED));
+        verify(runListener, times(1)).testFailure(any(Failure.class));
+        verify(runListener, times(1)).testFinished(
+                argThat(desc -> desc.isTest() && 
Objects.equals(desc.getMethodName(), "testFailed")));
+
+        // start test method (skipped)
+        
runListenerAdapter.executionSkipped(getTestIdentifierForMethod("testSkipped"), 
null);
+        verify(runListener, times(1)).testIgnored(
+                argThat(desc -> desc.isTest() && 
Objects.equals(desc.getMethodName(), "testSkipped")));
+
+        runListenerAdapter.executionFinished(getTestIdentifierForClass(), 
getTestExecutionResult(TestExecutionResult.Status.FAILED));
+        verify(runListener, times(1)).testSuiteFinished(
+                argThat(desc -> Objects.equals(desc.getClassName(), 
JUnit4SlingJUnit.class.getName())));
+
+        runListenerAdapter.testPlanExecutionFinished(testPlan);
+        verify(runListener, times(1)).testRunFinished(
+                argThat(r ->
+                        !r.wasSuccessful()
+                        && r.getRunCount() == 2
+                        && r.getFailureCount() == 1
+                        && r.getIgnoreCount() == 1
+                        && r.getAssumptionFailureCount() == 0
+                        && r.getRunTime() > 0));
+        
+        verifyNoMoreInteractions(runListener);
+    }
+
+    public TestExecutionResult 
getTestExecutionResult(TestExecutionResult.Status status) {
+        final TestExecutionResult result = mock(TestExecutionResult.class);
+        when(result.getStatus()).thenReturn(status);
+        return result;
+    }
+
+    public TestIdentifier getTestIdentifierForClass() {
+        TestDescriptor testDescriptor = mock(TestDescriptor.class);
+        when(testDescriptor.getUniqueId()).thenReturn(mock(UniqueId.class));
+        
when(testDescriptor.getType()).thenReturn(TestDescriptor.Type.CONTAINER);
+        when(testDescriptor.isTest()).thenReturn(false);
+        
when(testDescriptor.getSource()).thenReturn(Optional.of(ClassSource.from(JUnit4SlingJUnit.class)));
+        when(testDescriptor.getChildren()).thenAnswer(m -> new 
LinkedHashSet<>(asList(
+                getTestIdentifierForMethod("testSuccessful"),
+                getTestIdentifierForMethod("testFailed"),
+                getTestIdentifierForMethod("testSkipped"))));
+        return TestIdentifier.from(testDescriptor);
+    }
+
+    public TestIdentifier getTestIdentifierForMethod(String methodName) {
+        TestDescriptor testDescriptor = mock(TestDescriptor.class);
+        when(testDescriptor.getUniqueId()).thenReturn(mock(UniqueId.class));
+        when(testDescriptor.getType()).thenReturn(TestDescriptor.Type.TEST);
+        when(testDescriptor.isTest()).thenReturn(true);
+        when(testDescriptor.getSource()).thenReturn(Optional.of(
+                MethodSource.from(JUnit4SlingJUnit.class.getName(), 
methodName)));
+        return TestIdentifier.from(testDescriptor);
+    }
+
+}
\ No newline at end of file
diff --git 
a/src/test/java/org/apache/sling/junit/sampletests/JUnit4SlingJUnit.java 
b/src/test/java/org/apache/sling/junit/sampletests/JUnit4SlingJUnit.java
new file mode 100644
index 0000000..dbc5765
--- /dev/null
+++ b/src/test/java/org/apache/sling/junit/sampletests/JUnit4SlingJUnit.java
@@ -0,0 +1,48 @@
+/*
+ * 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.sampletests;
+
+import org.junit.Ignore;
+import org.junit.Test;
+
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Pseudo test-case class executed tests in {@link 
org.apache.sling.junit.impl.TestsManagerImplTest}.
+ *
+ * Name does not match normal JUnit patterns in order NOT to be included in 
the normal build's tests.
+ */
+public class JUnit4SlingJUnit {
+
+    @Test
+    public void testSuccessful() {
+        assertTrue(true);
+    }
+
+    @Test @Ignore("skipped for testing")
+    public void testSkipped() {
+        assertTrue(true);
+    }
+
+    @Test
+    public void testFailed() {
+        fail();
+    }
+}

Reply via email to