You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@sling.apache.org by js...@apache.org on 2020/10/06 13:12:47 UTC

[sling-org-apache-sling-junit-core] 01/01: SLING-9795 - JUnit 5 support for server-side tests

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

jsedding pushed a commit to branch issues/SLING-9795-junit5-support-for-server-side-tests
in repository https://gitbox.apache.org/repos/asf/sling-org-apache-sling-junit-core.git

commit 3d13ef6d32521622bac88ed9bf8779efd171bd74
Author: Julian Sedding <js...@apache.org>
AuthorDate: Tue Oct 6 15:12:19 2020 +0200

    SLING-9795 - JUnit 5 support for server-side tests
---
 bnd.bnd                                            |  14 +-
 pom.xml                                            |  45 +++-
 .../java/org/apache/sling/junit/TestsManager.java  |  15 +-
 .../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      | 246 +++++++--------------
 .../junit/impl/JUnit4TestExecutionStrategy.java    |  68 ++++++
 .../junit/impl/TestContextRunListenerWrapper.java  |   2 +-
 .../sling/junit/impl/TestExecutionStrategy.java    |  33 +++
 .../apache/sling/junit/impl/TestsManagerImpl.java  | 217 ++++++------------
 .../sling/junit/impl/servlet/ServletProcessor.java |  33 ++-
 .../impl/servlet/junit5/DescriptionGenerator.java  |  65 ++++++
 .../junit/impl/servlet/junit5/FailureHelper.java   |  50 +++++
 .../junit5/JUnit5TestExecutionStrategy.java        | 125 +++++++++++
 .../junit/impl/servlet/junit5/ResultAdapter.java   |  80 +++++++
 .../impl/servlet/junit5/RunListenerAdapter.java    | 126 +++++++++++
 .../impl/servlet/junit5/TestEngineTracker.java     |  91 ++++++++
 src/main/resources/junit.css                       |   3 +
 .../sling/junit/impl/TestsManagerImplTest.java     |  74 ++++---
 20 files changed, 945 insertions(+), 394 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..04be215 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>
@@ -60,6 +60,17 @@
                     <excludePackageNames>org.apache.sling.junit.impl;org.apache.sling.junit.impl.*</excludePackageNames>
                 </configuration>
             </plugin>
+            <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>
         </plugins>
     </build>
 
@@ -117,6 +128,17 @@
 
     <dependencies>
         <dependency>
+            <groupId>biz.aQute.bnd</groupId>
+            <artifactId>bnd-baseline-maven-plugin</artifactId>
+            <version>5.0.0</version>
+        </dependency>
+        <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 +215,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.
         -->
@@ -215,15 +246,15 @@
             <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/TestsManager.java b/src/main/java/org/apache/sling/junit/TestsManager.java
index 497ad0b..5cdba5a 100644
--- a/src/main/java/org/apache/sling/junit/TestsManager.java
+++ b/src/main/java/org/apache/sling/junit/TestsManager.java
@@ -34,12 +34,6 @@ public interface TestsManager {
     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();
-
-    /**
      * Instantiate test class for specified test
      *
      * @param testName the test class
@@ -66,4 +60,13 @@ public interface TestsManager {
      * @throws Exception if any error occurs
      */
     void executeTests(Collection<String> testNames, Renderer renderer, 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.
+     */
+    @Deprecated
+    void clearCaches();
 }
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..5ce5489 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,24 @@
 package org.apache.sling.junit.impl;
 
 import java.net.URL;
-import java.util.ArrayList;
+import java.util.Collection;
 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.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
+import java.util.stream.Collectors;
 
-import org.apache.sling.junit.TestsProvider;
+import org.jetbrains.annotations.Nullable;
 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.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;
 
@@ -40,190 +43,99 @@ import org.slf4j.LoggerFactory;
  *  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;
-    
+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 IllegalArgumentException("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);
+            return getTestClasses(bundle);
         }
     }
 
-    /** 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);
-            }
-            catch (PatternSyntaxException pse) {
-                log.warn("Invalid pattern '" + headerValue + "' for bundle "
-                                + b.getSymbolicName() + ", ignored", pse);
-            }
-        }
-        
-        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);
-            while (classUrls.hasMoreElements()) {
-                URL url = classUrls.nextElement();
-                final String name = toClassName(url);
-                if(testClassRegexp.matcher(name).matches()) {
-                    result.add(name);
-                } else {
-                    log.debug("Class {} does not match {} pattern {} of bundle {}, ignored",
-                            new Object[] { name, SLING_TEST_REGEXP, testClassRegexp, b.getSymbolicName() });
-                }
-            }
-            log.info("{} test classes found in bundle {}", result.size(), b.getSymbolicName());
+    @Nullable
+    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 null;
         }
-        
-        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;
-            }
+        Pattern testClassRegexp;
+        try {
+            testClassRegexp = Pattern.compile(headerValue);
+        } catch (PatternSyntaxException pse) {
+            LOG.warn("Bundle '{}' has an invalid pattern for {} header, ignored: '{}'",
+                    bundle.getSymbolicName(), SLING_TEST_REGEXP, headerValue);
+            return null;
         }
-        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;
+
+        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()) {
+                result.add(name);
+            } else {
+                LOG.debug("Class '{}' does not match {} pattern '{}' of bundle '{}', ignored",
+                        name, SLING_TEST_REGEXP, testClassRegexp, bundle.getSymbolicName());
             }
         }
-        
-        if(b == null) {
-            throw new IllegalArgumentException("No Bundle found that supplies test class " + testName);
-        }
-        return b.loadClass(testName);
-    }
 
-    public long lastModified() {
-        return lastModified;
+        LOG.info("{} test classes found in bundle '{}'", result.size(), bundle.getSymbolicName());
+        return result.isEmpty() ? null : result;
     }
 
-    public String getServicePid() {
-        return componentName;
+    private static String getSlingTestRegexp(Bundle bundle) {
+        return bundle.getHeaders().get(SLING_TEST_REGEXP);
     }
 
-    public List<String> getTestNames() {
-        maybeUpdateTestClasses();
-        final List<String> result = new ArrayList<String>();
-        for(List<String> list : testClassesMap.values()) {
-            result.addAll(list);
-        }
-        return result;
+    /** 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('/', '.');
     }
 }
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..04cbaf6
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/JUnit4TestExecutionStrategy.java
@@ -0,0 +1,68 @@
+/*
+ * 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.Renderer;
+import org.apache.sling.junit.SlingTestContextProvider;
+import org.apache.sling.junit.TestSelector;
+import org.junit.runner.JUnitCore;
+import org.junit.runner.Request;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+
+public class JUnit4TestExecutionStrategy implements TestExecutionStrategy {
+
+    private static final Logger LOG = LoggerFactory.getLogger(JUnit4TestExecutionStrategy.class);
+
+    private final TestsManagerImpl testsManager;
+
+    public JUnit4TestExecutionStrategy(TestsManagerImpl testsManager) {
+        this.testsManager = testsManager;
+    }
+
+    @Override
+    public void execute(Renderer renderer, Collection<String> testNames, TestSelector selector) throws Exception {
+        final JUnitCore junit = new JUnitCore();
+        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(testsManager.getTestClass(className), testMethodName));
+            } else {
+                LOG.debug("Running test class {}", className);
+                junit.run(testsManager.getTestClass(className));
+            }
+        }
+    }
+
+    @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..2d5cee0 100644
--- a/src/main/java/org/apache/sling/junit/impl/TestContextRunListenerWrapper.java
+++ b/src/main/java/org/apache/sling/junit/impl/TestContextRunListenerWrapper.java
@@ -29,7 +29,7 @@ public class TestContextRunListenerWrapper extends RunListener {
     private long testStartTime;
     private static final Logger log = LoggerFactory.getLogger(TestContextRunListenerWrapper.class);
     
-    TestContextRunListenerWrapper(RunListener toWrap) {
+    public TestContextRunListenerWrapper(RunListener toWrap) {
         wrapped = toWrap;
     }
 
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..454e182
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/TestExecutionStrategy.java
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.sling.junit.impl;
+
+import org.apache.sling.junit.Renderer;
+import org.apache.sling.junit.TestSelector;
+
+import java.io.Closeable;
+import java.util.Collection;
+
+public interface TestExecutionStrategy extends Closeable {
+
+    void execute(Renderer renderer, Collection<String> testNames, TestSelector selector) 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..1f0d361 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,31 @@
  */
 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.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.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.Collection;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
 @Component
 public class TestsManagerImpl implements TestsManager {
 
@@ -54,166 +49,91 @@ 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 final int startupTimeoutSeconds = Integer.parseInt(System.getProperty(PROP_STARTUP_TIMEOUT_SECONDS, "40"));
 
-    private static volatile boolean waitForSystemStartup = true;
+    private volatile boolean waitForSystemStartup = true;
 
-    private ServiceTracker tracker;
-
-    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;
+
+    @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);
+        }
     }
 
-    protected void deactivate(ComponentContext ctx) {
-        if(tracker != null) {
-            tracker.close();
+    @Deactivate
+    protected void deactivate() {
+        if(testsProviderTracker != null) {
+            testsProviderTracker.close();
+            testsProviderTracker = null;
+        }
+
+        if (executionStrategy != null) {
+            executionStrategy.close();
+            executionStrategy = null;
         }
-        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);
-        }
-        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);
-        }
+        final TestsProvider provider = getTestProviders()
+                .filter(p -> p.getTestNames().contains(testName))
+                .findFirst()
+                .orElseThrow(() -> new IllegalStateException("No TestsProvider found for test '" + testName + "'"));
 
         log.debug("Using provider {} to create test class {}", provider, testName);
         return provider.createTestClass(testName);
     }
 
+    @Override
     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();
+        final List<String> tests = getTestProviders()
+                .map(TestsProvider::getTestNames)
+                .flatMap(Collection::stream)
+                .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);
-        }
-        lastTrackingCount = tracker.getTrackingCount();
+
+    private Stream<TestsProvider> getTestProviders() {
+        return testsProviderTracker.getTracked().values().stream();
     }
 
+    @Override
     public void executeTests(Collection<String> testNames, Renderer renderer, 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();
         }
-        
+
         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));
-                }
-            }
+            executionStrategy.execute(renderer, testNames, selector);
         } finally {
             if(createContext) {
                 SlingTestContextProvider.deleteContext();
@@ -221,6 +141,7 @@ public class TestsManagerImpl implements TestsManager {
         }
     }
 
+    @Override
     public void listTests(Collection<String> testNames, Renderer renderer) {
         renderer.title(2, "Test classes");
         final String note = "The test set can be restricted using partial test names"
@@ -230,15 +151,17 @@ public class TestsManagerImpl implements TestsManager {
         renderer.list("testNames", testNames);
     }
 
+    @Override
+    public void clearCaches() {
+    }
 
     /** 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)) {
@@ -280,7 +203,7 @@ 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();
     }
 
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..25a654c 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,8 +19,8 @@ package org.apache.sling.junit.impl.servlet;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.ArrayList;
 import java.util.Collections;
-import java.util.LinkedList;
 import java.util.List;
 
 import javax.servlet.ServletException;
@@ -53,14 +53,10 @@ public class ServletProcessor {
     }
 
     /** Return sorted list of available tests
-     * @param prefix optionally select only names that match this prefix
+     * @param selector optionally select only names that match this selector
      */
-    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));
+    private List<String> getTestNames(TestSelector selector) {
+        final List<String> result = new ArrayList<>(testsManager.getTestNames(selector));
         Collections.sort(result);
         return result;
     }
@@ -81,16 +77,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,7 +101,7 @@ public class ServletProcessor {
         }
 
         final TestSelector selector = getTestSelector(request);
-        final List<String> testNames = getTestNames(selector, forceReload);
+        final List<String> testNames = getTestNames(selector);
         
         // 404 if no tests found
         if(testNames.isEmpty()) {
@@ -137,10 +134,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,7 +145,7 @@ public class ServletProcessor {
         }
         renderer.setup(response, getClass().getSimpleName());
 
-        final List<String> testNames = getTestNames(selector, forceReload);
+        final List<String> testNames = getTestNames(selector);
         if(testNames.isEmpty()) {
             response.sendError(
                     HttpServletResponse.SC_NOT_FOUND,
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..b509e9b
--- /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;
+
+    <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..8241a06
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/FailureHelper.java
@@ -0,0 +1,50 @@
+/*
+ * 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.engine.TestExecutionResult;
+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, TestExecutionResult result) {
+        return convert(testIdentifier, result.getThrowable().orElse(null));
+    }
+
+    @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..75b2ff4
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/JUnit5TestExecutionStrategy.java
@@ -0,0 +1,125 @@
+/*
+ * 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.Renderer;
+import org.apache.sling.junit.SlingTestContextProvider;
+import org.apache.sling.junit.TestSelector;
+import org.apache.sling.junit.impl.TestContextRunListenerWrapper;
+import org.apache.sling.junit.impl.TestExecutionStrategy;
+import org.apache.sling.junit.impl.TestsManagerImpl;
+import org.junit.platform.engine.discovery.ClassSelector;
+import org.junit.platform.engine.discovery.DiscoverySelectors;
+import org.junit.platform.engine.support.descriptor.ClassSource;
+import org.junit.platform.launcher.Launcher;
+import org.junit.platform.launcher.TestExecutionListener;
+import org.junit.platform.launcher.TestIdentifier;
+import org.junit.platform.launcher.core.LauncherConfig;
+import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
+import org.junit.platform.launcher.core.LauncherFactory;
+import org.osgi.framework.BundleContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
+import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod;
+
+public class JUnit5TestExecutionStrategy implements TestExecutionStrategy {
+
+    private static final Logger LOG = LoggerFactory.getLogger(JUnit5TestExecutionStrategy.class);
+
+    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(Renderer renderer, Collection<String> testNames, TestSelector selector) throws Exception {
+        TestExecutionListener listener = new TestExecutionListener() {
+            @Override
+            public void executionStarted(TestIdentifier testIdentifier) {
+                testIdentifier.getSource().ifPresent(src -> {
+                    if (src instanceof ClassSource) {
+                        final String className = ((ClassSource) src).getClassName();
+                        renderer.title(3, className);
+                    }
+                });
+
+                // If we have a test context, clear its output metadata
+                if (SlingTestContextProvider.hasContext()) {
+                    SlingTestContextProvider.getContext().output().clear();
+                }
+            }
+        };
+        Launcher launcher = LauncherFactory.create(
+                LauncherConfig.builder()
+                        .addTestEngines(testEngineTracker.getAvailableTestEngines())
+                        .addTestExecutionListeners(listener, new RunListenerAdapter(new TestContextRunListenerWrapper(renderer.getRunListener())))
+                        .enableTestEngineAutoRegistration(false)
+                        .enableTestExecutionListenerAutoRegistration(false)
+                        .build()
+        );
+
+        final LauncherDiscoveryRequestBuilder requestBuilder = LauncherDiscoveryRequestBuilder.request();
+        if (testNames.size() == 1) {
+            final String className = testNames.iterator().next();
+            final Class<?> testClass = testsManager.getTestClass(className);
+            final String testMethodName = selector == null ? null : selector.getSelectedTestMethodName();
+            if (testMethodName != null && testMethodName.length() > 0) {
+                LOG.debug("Running test method {} from test class {}", testMethodName, className);
+                requestBuilder.selectors(selectMethod(testClass, testMethodName));
+            } else {
+                LOG.debug("Running test class {}", className);
+                requestBuilder.selectors(selectClass(testClass));
+            }
+        } else {
+            final List<ClassSelector> testSelectors = testNames.stream()
+                    .map(className -> {
+                        try {
+                            return testsManager.getTestClass(className);
+                        } catch (ClassNotFoundException e) {
+                            LOG.warn("Failed to find test class '{}'", className);
+                            return null;
+                        }
+                    })
+                    .filter(Objects::nonNull)
+                    .map(DiscoverySelectors::selectClass)
+                    .collect(Collectors.toList());
+
+            requestBuilder.selectors(testSelectors);
+        }
+
+        launcher.execute(requestBuilder.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..8c58f08
--- /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 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..b34ef0a
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/RunListenerAdapter.java
@@ -0,0 +1,126 @@
+/*
+ * 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);
+    }
+
+    @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::testRunStarted);
+        }
+    }
+
+    @Override
+    public void executionSkipped(TestIdentifier testIdentifier, String reason) {
+        summarizer.executionSkipped(testIdentifier, reason);
+        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);
+        }
+    }
+
+    @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..6f19702
--- /dev/null
+++ b/src/main/java/org/apache/sling/junit/impl/servlet/junit5/TestEngineTracker.java
@@ -0,0 +1,91 @@
+/*
+ * 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.IdentityHashMap;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+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 Iterable<TestEngine> testEngines =
+                    ServiceLoader.load(TestEngine.class, bundle.adapt(BundleWiring.class).getClassLoader());
+            return StreamSupport.stream(testEngines.spliterator(), false)
+                    .peek(testEngine -> LOG.info("Found TestEngine '{}' in bundle '{}'", testEngine.getId(), bundle.getSymbolicName()))
+                    .collect(Collectors.toCollection(() -> Collections.newSetFromMap(new IdentityHashMap<>())));
+        }
+    }
+}
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/TestsManagerImplTest.java b/src/test/java/org/apache/sling/junit/impl/TestsManagerImplTest.java
index 8b394f4..889233c 100644
--- a/src/test/java/org/apache/sling/junit/impl/TestsManagerImplTest.java
+++ b/src/test/java/org/apache/sling/junit/impl/TestsManagerImplTest.java
@@ -16,34 +16,28 @@
  */
 package org.apache.sling.junit.impl;
 
+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.powermock.api.mockito.PowerMockito.mock;
-import static org.powermock.api.mockito.PowerMockito.when;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 import java.util.HashSet;
 import java.util.Hashtable;
 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 org.osgi.framework.wiring.BundleWiring;
 
 /**
  * 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 {
@@ -55,59 +49,67 @@ public class TestsManagerImplTest {
    * 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 {
+  public void needToWaitPositiveNotEmptyListNotGloballyTimeout() {
     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));
+    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() throws Exception {
+  public void needToWaitNegativeForstartupTimeout() {
     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>()));
+    assertFalse(TestsManagerImpl.needToWait(startupTimeout, emptySet()));
   }
 
   /**
    * case if needToWait should return false, when for example it reached the global timeout limit.
    */
   @Test
-  public void needToWaitNegativeForEmptyList() throws Exception {
+  public void needToWaitNegativeForEmptyList() {
     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>()));
+    assertFalse(TestsManagerImpl.needToWait(startupTimeout, emptySet()));
   }
 
   @Test
   public void waitForSystemStartupTimeout() {
-    setupBundleContextMock(Bundle.INSTALLED);
-    final long elapsed = TestsManagerImpl.waitForSystemStartup();
+    BundleContext bundleContext = setupBundleContext(Bundle.INSTALLED);
+    TestsManagerImpl testsManager = new TestsManagerImpl();
+    testsManager.activate(bundleContext);
+    
+    final long elapsed = testsManager.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"));
+    assertTrue(testsManager.isReady());
   }
 
   @Test
   public void waitForSystemStartupAllActiveBundles() {
-    setupBundleContextMock(Bundle.ACTIVE);
-    final long elapsed = TestsManagerImpl.waitForSystemStartup();
+    BundleContext bundleContext = setupBundleContext(Bundle.ACTIVE);
+    TestsManagerImpl testsManager = new TestsManagerImpl();
+    testsManager.activate(bundleContext);
+
+    final long elapsed = testsManager.waitForSystemStartup();
     assertTrue(elapsed < TimeUnit.SECONDS.toMillis(SYSTEM_STARTUP_SECONDS));
-    assertFalse((Boolean) Whitebox.getInternalState(TestsManagerImpl.class, "waitForSystemStartup"));
+    assertTrue(testsManager.isReady());
   }
 
-  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 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;
   }
 }
\ No newline at end of file