You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@groovy.apache.org by jw...@apache.org on 2017/07/01 15:54:59 UTC

groovy git commit: GROOVY-8197: Make JUnit3/4 GroovyRunners (closes #564)

Repository: groovy
Updated Branches:
  refs/heads/master 85af40b3e -> a2fb91d60


GROOVY-8197: Make JUnit3/4 GroovyRunners (closes #564)


Project: http://git-wip-us.apache.org/repos/asf/groovy/repo
Commit: http://git-wip-us.apache.org/repos/asf/groovy/commit/a2fb91d6
Tree: http://git-wip-us.apache.org/repos/asf/groovy/tree/a2fb91d6
Diff: http://git-wip-us.apache.org/repos/asf/groovy/diff/a2fb91d6

Branch: refs/heads/master
Commit: a2fb91d60ff6a917a86d1fd0f7266206dc0508fc
Parents: 85af40b
Author: John Wagenleitner <jw...@apache.org>
Authored: Sat Jul 1 08:45:32 2017 -0700
Committer: John Wagenleitner <jw...@apache.org>
Committed: Sat Jul 1 08:46:51 2017 -0700

----------------------------------------------------------------------
 src/main/groovy/grape/GrapeIvy.groovy           |  46 +-
 src/main/groovy/lang/GroovyShell.java           | 151 +------
 src/main/groovy/lang/GroovySystem.java          |   9 +-
 .../apache/groovy/plugin/DefaultRunners.java    | 218 ++++++++++
 .../org/apache/groovy/plugin/GroovyRunner.java  |  49 +++
 .../groovy/plugin/GroovyRunnerRegistry.java     | 436 +++++++++++++++++++
 .../codehaus/groovy/plugin/GroovyRunner.java    |  10 +-
 .../groovy/vmplugin/v5/JUnit4Utils.java         |   4 +-
 .../plugin/GroovyRunnerRegistryTest.groovy      | 192 ++++++++
 subprojects/groovy-testng/build.gradle          |   1 +
 .../groovy/plugin/testng/TestNgRunner.java      |  86 ++++
 .../codehaus/groovy/testng/TestNgRunner.java    |  74 +---
 .../org.apache.groovy.plugin.GroovyRunner       |  20 +
 .../org.codehaus.groovy.plugins.Runners         |  16 -
 .../plugin/testng/TestNgRunnerTest.groovy       |  41 ++
 15 files changed, 1114 insertions(+), 239 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/main/groovy/grape/GrapeIvy.groovy
----------------------------------------------------------------------
diff --git a/src/main/groovy/grape/GrapeIvy.groovy b/src/main/groovy/grape/GrapeIvy.groovy
index c7f7b45..f3e0958 100644
--- a/src/main/groovy/grape/GrapeIvy.groovy
+++ b/src/main/groovy/grape/GrapeIvy.groovy
@@ -18,6 +18,9 @@
  */
 package groovy.grape
 
+import org.apache.groovy.plugin.GroovyRunner
+import org.apache.groovy.plugin.GroovyRunnerRegistry
+
 import java.util.regex.Pattern
 import org.apache.ivy.Ivy
 import org.apache.ivy.core.cache.ResolutionCacheManager
@@ -62,6 +65,9 @@ class GrapeIvy implements GrapeEngine {
 
     static final int DEFAULT_DEPTH = 3
 
+    private static final String METAINF_PREFIX = 'META-INF/services/'
+    private static final String RUNNER_PROVIDER_CONFIG = GroovyRunner.class.getName()
+
     private final exclusiveGrabArgs = [
             ['group', 'groupId', 'organisation', 'organization', 'org'],
             ['module', 'artifactId', 'artifact'],
@@ -258,11 +264,18 @@ class GrapeIvy implements GrapeEngine {
             for (URI uri in uris) {
                 loader.addURL(uri.toURL())
             }
+            boolean runnerServicesFound = false
             for (URI uri in uris) {
                 //TODO check artifact type, jar vs library, etc
                 File file = new File(uri)
                 processCategoryMethods(loader, file)
-                processOtherServices(loader, file)
+                Collection<String> services = processMetaInfServices(loader, file)
+                if (!runnerServicesFound) {
+                    runnerServicesFound = services.contains(RUNNER_PROVIDER_CONFIG)
+                }
+            }
+            if (runnerServicesFound) {
+                GroovyRunnerRegistry.getInstance().load(loader)
             }
         } catch (Exception e) {
             // clean-up the state first
@@ -313,20 +326,44 @@ class GrapeIvy implements GrapeEngine {
     }
 
     void processOtherServices(ClassLoader loader, File f) {
+        processMetaInfServices(loader, f) // ignore result
+    }
+
+    /**
+     * Searches the given File for known service provider
+     * configuration files to process.
+     *
+     * @param loader used to locate service provider files
+     * @param f ZipFile in which to search for services
+     * @return a collection of service provider files that were found
+     */
+    private Collection<String> processMetaInfServices(ClassLoader loader, File f) {
+        List<String> services = new ArrayList<>()
         try {
             ZipFile zf = new ZipFile(f)
-            ZipEntry serializedCategoryMethods = zf.getEntry("META-INF/services/org.codehaus.groovy.runtime.SerializedCategoryMethods")
+            String providerConfig = 'org.codehaus.groovy.runtime.SerializedCategoryMethods'
+            ZipEntry serializedCategoryMethods = zf.getEntry(METAINF_PREFIX + providerConfig)
             if (serializedCategoryMethods != null) {
+                services.add(providerConfig)
                 processSerializedCategoryMethods(zf.getInputStream(serializedCategoryMethods))
             }
-            ZipEntry pluginRunners = zf.getEntry("META-INF/services/org.codehaus.groovy.plugins.Runners")
+            // TODO: remove in a future release (replaced by GroovyRunnerRegistry)
+            providerConfig = 'org.codehaus.groovy.plugins.Runners'
+            ZipEntry pluginRunners = zf.getEntry(METAINF_PREFIX + providerConfig)
             if (pluginRunners != null) {
+                services.add(providerConfig)
                 processRunners(zf.getInputStream(pluginRunners), f.getName(), loader)
             }
+            // GroovyRunners are loaded per ClassLoader using a ServiceLoader so here
+            // it only needs to be indicated that the service provider file was found
+            if (zf.getEntry(METAINF_PREFIX + RUNNER_PROVIDER_CONFIG) != null) {
+                services.add(RUNNER_PROVIDER_CONFIG)
+            }
         } catch(ZipException ignore) {
             // ignore files we can't process, e.g. non-jar/zip artifacts
             // TODO log a warning
         }
+        return services
     }
 
     void processSerializedCategoryMethods(InputStream is) {
@@ -336,9 +373,10 @@ class GrapeIvy implements GrapeEngine {
     }
 
     void processRunners(InputStream is, String name, ClassLoader loader) {
+        GroovyRunnerRegistry registry = GroovyRunnerRegistry.getInstance()
         is.text.readLines()*.trim().findAll{ !it.isEmpty() && it[0] != '#' }.each {
             try {
-                GroovySystem.RUNNER_REGISTRY[name] = loader.loadClass(it).newInstance()
+                registry[name] = loader.loadClass(it).newInstance()
             } catch (Exception ex) {
                 throw new IllegalStateException("Error registering runner class '" + it + "'", ex)
             }

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/main/groovy/lang/GroovyShell.java
----------------------------------------------------------------------
diff --git a/src/main/groovy/lang/GroovyShell.java b/src/main/groovy/lang/GroovyShell.java
index 1d881ae..7b404cb 100644
--- a/src/main/groovy/lang/GroovyShell.java
+++ b/src/main/groovy/lang/GroovyShell.java
@@ -21,9 +21,10 @@ package groovy.lang;
 import groovy.ui.GroovyMain;
 import groovy.security.GroovyCodeSourcePermission;
 
+import org.apache.groovy.plugin.GroovyRunner;
+import org.apache.groovy.plugin.GroovyRunnerRegistry;
 import org.codehaus.groovy.control.CompilationFailedException;
 import org.codehaus.groovy.control.CompilerConfiguration;
-import org.codehaus.groovy.plugin.GroovyRunner;
 import org.codehaus.groovy.runtime.InvokerHelper;
 import org.codehaus.groovy.runtime.InvokerInvocationException;
 
@@ -36,7 +37,6 @@ import java.security.PrivilegedAction;
 import java.security.PrivilegedActionException;
 import java.security.PrivilegedExceptionAction;
 import java.util.List;
-import java.util.Map;
 
 /**
  * Represents a groovy shell capable of running arbitrary groovy scripts
@@ -290,21 +290,9 @@ public class GroovyShell extends GroovyObjectSupport {
             if (Runnable.class.isAssignableFrom(scriptClass)) {
                 return runRunnable(scriptClass, args);
             }
-            // if it's a JUnit 3.8.x test, run it with an appropriate runner
-            if (isJUnit3Test(scriptClass)) {
-                return runJUnit3Test(scriptClass);
-            }
-            // if it's a JUnit 3.8.x test suite, run it with an appropriate runner
-            if (isJUnit3TestSuite(scriptClass)) {
-                return runJUnit3TestSuite(scriptClass);
-            }
-            // if it's a JUnit 4.x test, run it with an appropriate runner
-            if (isJUnit4Test(scriptClass)) {
-                return runJUnit4Test(scriptClass);
-            }
-            for (Map.Entry<String, GroovyRunner> entry : GroovySystem.RUNNER_REGISTRY.entrySet()) {
-                GroovyRunner runner = entry.getValue();
-                if (runner != null && runner.canRun(scriptClass, this.loader)) {
+            GroovyRunnerRegistry runnerRegistry = GroovyRunnerRegistry.getInstance();
+            for (GroovyRunner runner : runnerRegistry) {
+                if (runner.canRun(scriptClass, this.loader)) {
                     return runner.run(scriptClass, this.loader);
                 }
             }
@@ -314,11 +302,12 @@ public class GroovyShell extends GroovyObjectSupport {
                     "- be a JUnit test or extend GroovyTestCase,\n" +
                     "- implement the Runnable interface,\n" +
                     "- or be compatible with a registered script runner. Known runners:\n";
-            if (GroovySystem.RUNNER_REGISTRY.isEmpty()) {
+            if (runnerRegistry.isEmpty()) {
                 message += "  * <none>";
-            }
-            for (Map.Entry<String, GroovyRunner> entry : GroovySystem.RUNNER_REGISTRY.entrySet()) {
-                message += "  * " + entry.getKey() + "\n";
+            } else {
+                for (String key : runnerRegistry.keySet()) {
+                    message += "  * " + key + "\n";
+                }
             }
             throw new GroovyRuntimeException(message);
         }
@@ -362,126 +351,6 @@ public class GroovyShell extends GroovyObjectSupport {
     }
 
     /**
-     * Run the specified class extending TestCase as a unit test.
-     * This is done through reflection, to avoid adding a dependency to the JUnit framework.
-     * Otherwise, developers embedding Groovy and using GroovyShell to load/parse/compile
-     * groovy scripts and classes would have to add another dependency on their classpath.
-     *
-     * @param scriptClass the class to be run as a unit test
-     */
-    private static Object runJUnit3Test(Class scriptClass) {
-        try {
-            Object testSuite = InvokerHelper.invokeConstructorOf("junit.framework.TestSuite", new Object[]{scriptClass});
-            return InvokerHelper.invokeStaticMethod("junit.textui.TestRunner", "run", new Object[]{testSuite});
-        } catch (ClassNotFoundException e) {
-            throw new GroovyRuntimeException("Failed to run the unit test. JUnit is not on the Classpath.", e);
-        }
-    }
-
-    /**
-     * Run the specified class extending TestSuite as a unit test.
-     * This is done through reflection, to avoid adding a dependency to the JUnit framework.
-     * Otherwise, developers embedding Groovy and using GroovyShell to load/parse/compile
-     * groovy scripts and classes would have to add another dependency on their classpath.
-     *
-     * @param scriptClass the class to be run as a unit test
-     */
-    private static Object runJUnit3TestSuite(Class scriptClass) {
-        try {
-            Object testSuite = InvokerHelper.invokeStaticMethod(scriptClass, "suite", new Object[]{});
-            return InvokerHelper.invokeStaticMethod("junit.textui.TestRunner", "run", new Object[]{testSuite});
-        } catch (ClassNotFoundException e) {
-            throw new GroovyRuntimeException("Failed to run the unit test. JUnit is not on the Classpath.", e);
-        }
-    }
-
-    private Object runJUnit4Test(Class scriptClass) {
-        try {
-            return InvokerHelper.invokeStaticMethod("org.codehaus.groovy.vmplugin.v5.JUnit4Utils",
-                    "realRunJUnit4Test", new Object[]{scriptClass, this.loader});
-        } catch (ClassNotFoundException e) {
-            throw new GroovyRuntimeException("Failed to run the JUnit 4 test.", e);
-        }
-    }
-
-    /**
-     * Utility method to check through reflection if the class appears to be a
-     * JUnit 3.8.x test, i.e. checks if it extends JUnit 3.8.x's TestCase.
-     *
-     * @param scriptClass the class we want to check
-     * @return true if the class appears to be a test
-     */
-    private boolean isJUnit3Test(Class scriptClass) {
-        // check if the parsed class is a GroovyTestCase,
-        // so that it is possible to run it as a JUnit test
-        boolean isUnitTestCase = false;
-        try {
-            try {
-                Class testCaseClass = this.loader.loadClass("junit.framework.TestCase");
-                // if scriptClass extends testCaseClass
-                if (testCaseClass.isAssignableFrom(scriptClass)) {
-                    isUnitTestCase = true;
-                }
-            } catch (ClassNotFoundException e) {
-                // fall through
-            }
-        } catch (Throwable e) {
-            // fall through
-        }
-        return isUnitTestCase;
-    }
-
-     /**
-     * Utility method to check through reflection if the class appears to be a
-     * JUnit 3.8.x test suite, i.e. checks if it extends JUnit 3.8.x's TestSuite.
-     *
-     * @param scriptClass the class we want to check
-     * @return true if the class appears to be a test
-     */
-    private boolean isJUnit3TestSuite(Class scriptClass) {
-        // check if the parsed class is a TestSuite,
-        // so that it is possible to run it as a JUnit test
-        boolean isUnitTestSuite = false;
-        try {
-            try {
-                Class testSuiteClass = this.loader.loadClass("junit.framework.TestSuite");
-                // if scriptClass extends TestSuiteClass
-                if (testSuiteClass.isAssignableFrom(scriptClass)) {
-                    isUnitTestSuite = true;
-                }
-            } catch (ClassNotFoundException e) {
-                // fall through
-            }
-        } catch (Throwable e) {
-            // fall through
-        }
-        return isUnitTestSuite;
-    }
-
-    /**
-     * Utility method to check via reflection if the parsed class appears to be a JUnit4
-     * test, i.e. checks whether it appears to be using the relevant JUnit 4 annotations.
-     *
-     * @param scriptClass the class we want to check
-     * @return true if the class appears to be a test
-     */
-    private boolean isJUnit4Test(Class scriptClass) {
-        // check if there are appropriate class or method annotations
-        // that suggest we have a JUnit 4 test
-        boolean isTest = false;
-
-        try {
-            if (InvokerHelper.invokeStaticMethod("org.codehaus.groovy.vmplugin.v5.JUnit4Utils",
-                    "realIsJUnit4Test", new Object[]{scriptClass, this.loader}) == Boolean.TRUE) {
-                isTest = true;
-            }
-        } catch (ClassNotFoundException e) {
-            throw new GroovyRuntimeException("Failed to invoke the JUnit 4 helper class.", e);
-        }
-        return isTest;
-    }
-
-    /**
      * Runs the given script text with command line arguments
      *
      * @param scriptText is the text content of the script

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/main/groovy/lang/GroovySystem.java
----------------------------------------------------------------------
diff --git a/src/main/groovy/lang/GroovySystem.java b/src/main/groovy/lang/GroovySystem.java
index 0a41035..cb5ea98 100644
--- a/src/main/groovy/lang/GroovySystem.java
+++ b/src/main/groovy/lang/GroovySystem.java
@@ -18,12 +18,12 @@
  */
 package groovy.lang;
 
-import org.codehaus.groovy.plugin.GroovyRunner;
+import org.apache.groovy.plugin.GroovyRunner;
+import org.apache.groovy.plugin.GroovyRunnerRegistry;
 import org.codehaus.groovy.runtime.metaclass.MetaClassRegistryImpl;
 import org.codehaus.groovy.util.ReferenceBundle;
 import org.codehaus.groovy.util.ReleaseInfo;
 
-import java.util.HashMap;
 import java.util.Map;
 
 public final class GroovySystem {
@@ -49,8 +49,11 @@ public final class GroovySystem {
 
     /**
      * Reference to the Runtime Registry to be used by the Groovy run-time system to find classes capable of running scripts
+     *
+     * @deprecated use {@link GroovyRunnerRegistry}
      */
-    public static final Map<String, GroovyRunner> RUNNER_REGISTRY = new HashMap<String, GroovyRunner>();
+    @Deprecated
+    public static final Map<String, GroovyRunner> RUNNER_REGISTRY = GroovyRunnerRegistry.getInstance();
 
     private static boolean keepJavaMetaClasses=false;
     

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/main/org/apache/groovy/plugin/DefaultRunners.java
----------------------------------------------------------------------
diff --git a/src/main/org/apache/groovy/plugin/DefaultRunners.java b/src/main/org/apache/groovy/plugin/DefaultRunners.java
new file mode 100644
index 0000000..5f570eb
--- /dev/null
+++ b/src/main/org/apache/groovy/plugin/DefaultRunners.java
@@ -0,0 +1,218 @@
+/*
+ *  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.groovy.plugin;
+
+import groovy.lang.GroovyClassLoader;
+import groovy.lang.GroovyRuntimeException;
+import org.codehaus.groovy.runtime.InvokerHelper;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+import java.util.List;
+
+/**
+ * Provides access to built-in {@link GroovyRunner} instances
+ * for the registry.  These instances should be accessed via
+ * the registry and not used directly.
+ */
+final class DefaultRunners {
+
+    /*
+     * These runners were originally included directly in GroovyShell.
+     * Since they are part of core they are added directly to the
+     * GroovyRunnerRegistry rather than via a provider configuration
+     * file in META-INF/services. If any of these runners are moved
+     * out to a submodule then they should be registered using the
+     * provider configuration file (see groovy-testng).
+     *
+     * These are internal classes and not meant to be referenced
+     * outside of the GroovyRunnerRegistry.
+     */
+
+    private static final GroovyRunner JUNIT3_TEST = new Junit3TestRunner();
+    private static final GroovyRunner JUNIT3_SUITE = new Junit3SuiteRunner();
+    private static final GroovyRunner JUNIT4_TEST = new Junit4TestRunner();
+
+    private DefaultRunners() {
+    }
+
+    static GroovyRunner junit3TestRunner() {
+        return JUNIT3_TEST;
+    }
+
+    static GroovyRunner junit3SuiteRunner() {
+        return JUNIT3_SUITE;
+    }
+
+    static GroovyRunner junit4TestRunner() {
+        return JUNIT4_TEST;
+    }
+
+    private static class Junit3TestRunner implements GroovyRunner {
+        /**
+         * Utility method to check through reflection if the class appears to be a
+         * JUnit 3.8.x test, i.e. checks if it extends JUnit 3.8.x's TestCase.
+         *
+         * @param scriptClass the class we want to check
+         * @param loader the class loader
+         * @return true if the class appears to be a test
+         */
+        @Override
+        public boolean canRun(Class<?> scriptClass, GroovyClassLoader loader) {
+            try {
+                Class<?> testCaseClass = loader.loadClass("junit.framework.TestCase");
+                return testCaseClass.isAssignableFrom(scriptClass);
+            } catch (Throwable e) {
+                return false;
+            }
+        }
+
+        /**
+         * Run the specified class extending TestCase as a unit test.
+         * This is done through reflection, to avoid adding a dependency to the JUnit framework.
+         * Otherwise, developers embedding Groovy and using GroovyShell to load/parse/compile
+         * groovy scripts and classes would have to add another dependency on their classpath.
+         *
+         * @param scriptClass the class to be run as a unit test
+         * @param loader the class loader
+         */
+        @Override
+        public Object run(Class<?> scriptClass, GroovyClassLoader loader) {
+            try {
+                Object testSuite = InvokerHelper.invokeConstructorOf("junit.framework.TestSuite", new Object[]{scriptClass});
+                return InvokerHelper.invokeStaticMethod("junit.textui.TestRunner", "run", new Object[]{testSuite});
+            } catch (ClassNotFoundException e) {
+                throw new GroovyRuntimeException("Failed to run the unit test. JUnit is not on the Classpath.", e);
+            }
+        }
+    }
+
+    private static class Junit3SuiteRunner implements GroovyRunner {
+        /**
+         * Utility method to check through reflection if the class appears to be a
+         * JUnit 3.8.x test suite, i.e. checks if it extends JUnit 3.8.x's TestSuite.
+         *
+         * @param scriptClass the class we want to check
+         * @param loader the class loader
+         * @return true if the class appears to be a test
+         */
+        @Override
+        public boolean canRun(Class<?> scriptClass, GroovyClassLoader loader) {
+            try {
+                Class<?> testSuiteClass = loader.loadClass("junit.framework.TestSuite");
+                return testSuiteClass.isAssignableFrom(scriptClass);
+            } catch (Throwable e) {
+                return false;
+            }
+        }
+
+        /**
+         * Run the specified class extending TestSuite as a unit test.
+         * This is done through reflection, to avoid adding a dependency to the JUnit framework.
+         * Otherwise, developers embedding Groovy and using GroovyShell to load/parse/compile
+         * groovy scripts and classes would have to add another dependency on their classpath.
+         *
+         * @param scriptClass the class to be run as a unit test
+         * @param loader the class loader
+         */
+        @Override
+        public Object run(Class<?> scriptClass, GroovyClassLoader loader) {
+            try {
+                Object testSuite = InvokerHelper.invokeStaticMethod(scriptClass, "suite", new Object[]{});
+                return InvokerHelper.invokeStaticMethod("junit.textui.TestRunner", "run", new Object[]{testSuite});
+            } catch (ClassNotFoundException e) {
+                throw new GroovyRuntimeException("Failed to run the unit test. JUnit is not on the Classpath.", e);
+            }
+        }
+    }
+
+    private static class Junit4TestRunner implements GroovyRunner {
+        /**
+         * Utility method to check via reflection if the parsed class appears to be a JUnit4
+         * test, i.e. checks whether it appears to be using the relevant JUnit 4 annotations.
+         *
+         * @param scriptClass the class we want to check
+         * @param loader the class loader
+         * @return true if the class appears to be a test
+         */
+        @Override
+        public boolean canRun(Class<?> scriptClass, GroovyClassLoader loader) {
+            return hasRunWithAnnotation(scriptClass, loader)
+                    || hasTestAnnotatedMethod(scriptClass, loader);
+        }
+
+        /**
+         * Run the specified class extending TestCase as a unit test.
+         * This is done through reflection, to avoid adding a dependency to the JUnit framework.
+         * Otherwise, developers embedding Groovy and using GroovyShell to load/parse/compile
+         * groovy scripts and classes would have to add another dependency on their classpath.
+         *
+         * @param scriptClass the class to be run as a unit test
+         * @param loader the class loader
+         */
+        @Override
+        public Object run(Class<?> scriptClass, GroovyClassLoader loader) {
+            try {
+                Class<?> junitCoreClass = loader.loadClass("org.junit.runner.JUnitCore");
+                Object result = InvokerHelper.invokeStaticMethod(junitCoreClass,
+                        "runClasses", new Object[]{scriptClass});
+                System.out.print("JUnit 4 Runner, Tests: " + InvokerHelper.getProperty(result, "runCount"));
+                System.out.print(", Failures: " + InvokerHelper.getProperty(result, "failureCount"));
+                System.out.println(", Time: " + InvokerHelper.getProperty(result, "runTime"));
+                List<?> failures = (List<?>) InvokerHelper.getProperty(result, "failures");
+                for (Object f : failures) {
+                    System.out.println("Test Failure: " + InvokerHelper.getProperty(f, "description"));
+                    System.out.println(InvokerHelper.getProperty(f, "trace"));
+                }
+                return result;
+            } catch (ClassNotFoundException e) {
+                throw new GroovyRuntimeException("Error running JUnit 4 test.", e);
+            }
+        }
+
+        private static boolean hasRunWithAnnotation(Class<?> scriptClass, ClassLoader loader) {
+            try {
+                @SuppressWarnings("unchecked")
+                Class<? extends Annotation> runWithAnnotationClass =
+                        (Class<? extends Annotation>)loader.loadClass("org.junit.runner.RunWith");
+                return scriptClass.isAnnotationPresent(runWithAnnotationClass);
+            } catch (Throwable e) {
+                return false;
+            }
+        }
+
+        private static boolean hasTestAnnotatedMethod(Class<?> scriptClass, ClassLoader loader) {
+            try {
+                @SuppressWarnings("unchecked")
+                Class<? extends Annotation> testAnnotationClass =
+                        (Class<? extends Annotation>) loader.loadClass("org.junit.Test");
+                Method[] methods = scriptClass.getMethods();
+                for (Method method : methods) {
+                    if (method.isAnnotationPresent(testAnnotationClass)) {
+                        return true;
+                    }
+                }
+            } catch (Throwable e) {
+                // fall through
+            }
+            return false;
+        }
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/main/org/apache/groovy/plugin/GroovyRunner.java
----------------------------------------------------------------------
diff --git a/src/main/org/apache/groovy/plugin/GroovyRunner.java b/src/main/org/apache/groovy/plugin/GroovyRunner.java
new file mode 100644
index 0000000..283d092
--- /dev/null
+++ b/src/main/org/apache/groovy/plugin/GroovyRunner.java
@@ -0,0 +1,49 @@
+/*
+ *  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.groovy.plugin;
+
+import groovy.lang.GroovyClassLoader;
+
+/**
+ * Classes which can run scripts should implement this interface.
+ *
+ * @since 2.5.0
+ */
+public interface GroovyRunner {
+
+    /**
+     * Returns {@code true} if this runner is able to
+     * run the given class.
+     *
+     * @param scriptClass class to run
+     * @param loader used to locate classes and resources
+     * @return true if given class can be run, else false
+     */
+    boolean canRun(Class<?> scriptClass, GroovyClassLoader loader);
+
+    /**
+     * Runs the given class.
+     *
+     * @param scriptClass class to run
+     * @param loader used to locate classes and resources
+     * @return result of running the class
+     */
+    Object run(Class<?> scriptClass, GroovyClassLoader loader);
+
+}

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/main/org/apache/groovy/plugin/GroovyRunnerRegistry.java
----------------------------------------------------------------------
diff --git a/src/main/org/apache/groovy/plugin/GroovyRunnerRegistry.java b/src/main/org/apache/groovy/plugin/GroovyRunnerRegistry.java
new file mode 100644
index 0000000..43ed8a4
--- /dev/null
+++ b/src/main/org/apache/groovy/plugin/GroovyRunnerRegistry.java
@@ -0,0 +1,436 @@
+/*
+ *  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.groovy.plugin;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.ServiceConfigurationError;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReadWriteLock;
+import java.util.concurrent.locks.ReentrantReadWriteLock;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Registry of services that implement the {@link GroovyRunner} interface.
+ * <p>
+ * This registry makes use of the {@link ServiceLoader} facility. The
+ * preferred method for registering new {@link GroovyRunner} providers
+ * is to place them in a provider-configuration file in the resource
+ * directory {@code META-INF/services}. The preferred method for accessing
+ * the registered runners is by making use of the {@code Iterable}
+ * interface using an enhanced for-loop.
+ * <p>
+ * For compatibility with previous versions, this registry implements the
+ * {@link Map} interface. All {@code null} keys and values will be ignored
+ * and no exception thrown, except where noted.
+ *
+ * @since 2.5.0
+ */
+public class GroovyRunnerRegistry implements Map<String, GroovyRunner>, Iterable<GroovyRunner> {
+
+    /*
+     * Implementation notes
+     *
+     * GroovySystem stores a static reference to this instance so it is
+     * important to make it fast to create as possible. GroovyRunners are
+     * only used to run scripts that GroovyShell does not already know how
+     * to run so defer service loading until requested via the iterator or
+     * map access methods.
+     *
+     * The Map interface is for compatibility with the original definition
+     * of GroovySystem.RUNNER_REGISTRY. At some point it would probably
+     * make sense to dispense with associating a String key with a runner
+     * and provide register/unregister methods instead of the Map
+     * interface.
+     */
+
+    private static final GroovyRunnerRegistry INSTANCE = new GroovyRunnerRegistry();
+
+    private static final Logger LOG = Logger.getLogger(GroovyRunnerRegistry.class.getName());
+
+    // Lazily initialized and loaded, should be accessed internally using getMap()
+    private volatile Map<String, GroovyRunner> runnerMap;
+
+    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
+    private final Lock readLock = rwLock.readLock();
+    private final Lock writeLock = rwLock.writeLock();
+
+    /**
+     * Returns a reference to the one and only registry instance.
+     *
+     * @return registry instance
+     */
+    public static GroovyRunnerRegistry getInstance() {
+        return INSTANCE;
+    }
+
+    // package-private for use in testing to avoid calling ServiceLoader.load
+    GroovyRunnerRegistry(Map<? extends String, ? extends GroovyRunner> runners) {
+        // Preserve insertion order
+        runnerMap = new LinkedHashMap<>();
+        putAll(runners);
+    }
+
+    private GroovyRunnerRegistry() {
+    }
+
+    /**
+     * Lazily initialize and load the backing Map. A {@link LinkedHashMap}
+     * is used to preserve insertion order.
+     * <p>
+     * Do not call while holding a read lock.
+     *
+     * @return backing registry map
+     */
+    private Map<String, GroovyRunner> getMap() {
+        Map<String, GroovyRunner> map = runnerMap;
+        if (map == null) {
+            writeLock.lock();
+            try {
+                if ((map = runnerMap) == null) {
+                    runnerMap = map = new LinkedHashMap<>();
+                    loadDefaultRunners();
+                    load(null);
+                }
+            } finally {
+                writeLock.unlock();
+            }
+        }
+        return map;
+    }
+
+    private void loadDefaultRunners() {
+        register(DefaultRunners.junit3TestRunner());
+        register(DefaultRunners.junit3SuiteRunner());
+        register(DefaultRunners.junit4TestRunner());
+    }
+
+    /**
+     * Loads {@link GroovyRunner} instances using the {@link ServiceLoader} facility.
+     *
+     * @param classLoader used to locate provider-configuration files and classes
+     */
+    public void load(ClassLoader classLoader) {
+        Map<String, GroovyRunner> map = runnerMap; // direct read
+        if (map == null) {
+            map = getMap(); // initialize and load (recursive call), result ignored
+            if (classLoader == null) {
+                // getMap() already loaded using a null classloader
+                return;
+            }
+        }
+        try {
+            if (classLoader == null) {
+                classLoader = Thread.currentThread().getContextClassLoader();
+            }
+            load0(classLoader);
+        } catch (SecurityException se) {
+            LOG.log(Level.WARNING, "Failed to get the context ClassLoader", se);
+        } catch (ServiceConfigurationError sce) {
+            LOG.log(Level.WARNING, "Failed to load GroovyRunner services from ClassLoader " + classLoader, sce);
+        }
+    }
+
+    private void load0(ClassLoader classLoader) {
+        ServiceLoader<GroovyRunner> serviceLoader = ServiceLoader.load(GroovyRunner.class, classLoader);
+        for (GroovyRunner runner : serviceLoader) {
+            register(runner);
+        }
+    }
+
+    /**
+     * Registers the given instance with the registry. This is
+     * equivalent to {@link #put(String, GroovyRunner)} with a
+     * {@code key} being set to {@code runner.getClass().getName()}.
+     *
+     * @param runner the instance to add to the registry
+     */
+    private void register(GroovyRunner runner) {
+        put(runner.getClass().getName(), runner);
+    }
+
+    /**
+     * Returns an iterator for all runners that are registered.
+     * The returned iterator is a snapshot of the registry at
+     * the time the iterator is created. This iterator does not
+     * support removal.
+     *
+     * @return iterator for all registered runners
+     */
+    @Override
+    public Iterator<GroovyRunner> iterator() {
+        return values().iterator();
+    }
+
+    /**
+     * Returns the number of registered runners.
+     *
+     * @return number of registered runners
+     */
+    @Override
+    public int size() {
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            return map.size();
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    /**
+     * Returns {@code true} if the registry contains no runners, else
+     * {@code false}.
+     *
+     * @return {@code true} if no runners are registered
+     */
+    @Override
+    public boolean isEmpty() {
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            return map.isEmpty();
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    /**
+     * Returns {@code true} if a runner was registered with the
+     * specified key.
+     *
+     * @param key for the registered runner
+     * @return {@code true} if a runner was registered with given key
+     */
+    @Override
+    public boolean containsKey(Object key) {
+        if (key == null) {
+            return false;
+        }
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            return map.containsKey(key);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    /**
+     * Returns {@code true} if registry contains the given
+     * runner instance.
+     *
+     * @param runner instance of a GroovyRunner
+     * @return {@code true} if the given runner is registered
+     */
+    @Override
+    public boolean containsValue(Object runner) {
+        if (runner == null) {
+            return false;
+        }
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            return map.containsValue(runner);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    /**
+     * Returns the registered runner for the specified key.
+     *
+     * @param key used to lookup the runner
+     * @return the runner registered with the given key
+     */
+    @Override
+    public GroovyRunner get(Object key) {
+        if (key == null) {
+            return null;
+        }
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            return map.get(key);
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    /**
+     * Registers a runner with the specified key.
+     *
+     * @param key to associate with the runner
+     * @param runner the runner to register
+     * @return the previously registered runner for the given key,
+     *          if no runner was previously registered for the key
+     *          then {@code null}
+     */
+    @Override
+    public GroovyRunner put(String key, GroovyRunner runner) {
+        if (key == null || runner == null) {
+            return null;
+        }
+        Map<String, GroovyRunner> map = getMap();
+        writeLock.lock();
+        try {
+            return map.put(key, runner);
+        } finally {
+            writeLock.unlock();
+        }
+    }
+
+    /**
+     * Removes a registered runner from the registry.
+     *
+     * @param key of the runner to remove
+     * @return the runner instance that was removed, if no runner
+     *          instance was removed then {@code null}
+     */
+    @Override
+    public GroovyRunner remove(Object key) {
+        if (key == null) {
+            return null;
+        }
+        Map<String, GroovyRunner> map = getMap();
+        writeLock.lock();
+        try {
+            return map.remove(key);
+        } finally {
+            writeLock.unlock();
+        }
+    }
+
+    /**
+     * Adds all entries from the given Map to the registry.
+     * Any entries in the provided Map that contain a {@code null}
+     * key or value will be ignored.
+     *
+     * @param m entries to add to the registry
+     * @throws NullPointerException if the given Map is {@code null}
+     */
+    @Override
+    public void putAll(Map<? extends String, ? extends GroovyRunner> m) {
+        writeLock.lock();
+        try {
+            for (Map.Entry<? extends String, ? extends GroovyRunner> entry : m.entrySet()) {
+                put(entry.getKey(), entry.getValue());
+            }
+        } finally {
+            writeLock.unlock();
+        }
+    }
+
+    /**
+     * Clears all registered runners from the registry.
+     */
+    @Override
+    public void clear() {
+        Map<String, GroovyRunner> map = getMap();
+        writeLock.lock();
+        try {
+            map.clear();
+        } finally {
+            writeLock.unlock();
+        }
+    }
+
+    /**
+     * Set of all keys associated with registered runners.
+     * This is a snapshot of the registry and any subsequent
+     * registry changes will not be reflected in the set.
+     *
+     * @return an unmodifiable set of keys for registered runners
+     */
+    @Override
+    public Set<String> keySet() {
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            if (map.isEmpty()) {
+                return Collections.emptySet();
+            }
+            return Collections.unmodifiableSet(new LinkedHashSet<>(map.keySet()));
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    /**
+     * Returns a collection of all registered runners.
+     * This is a snapshot of the registry and any subsequent
+     * registry changes will not be reflected in the collection.
+     *
+     * @return an unmodifiable collection of registered runner instances
+     */
+    @Override
+    public Collection<GroovyRunner> values() {
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            if (map.isEmpty()) {
+                return Collections.emptyList();
+            }
+            return Collections.unmodifiableCollection(new ArrayList<>(map.values()));
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    /**
+     * Returns a set of entries for registered runners.
+     * This is a snapshot of the registry and any subsequent
+     * registry changes will not be reflected in the set.
+     *
+     * @return an unmodifiable set of registered runner entries
+     */
+    @Override
+    public Set<Entry<String, GroovyRunner>> entrySet() {
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            if (map.isEmpty()) {
+                return Collections.emptySet();
+            }
+            return Collections.unmodifiableSet(new LinkedHashSet<>(map.entrySet()));
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+    @Override
+    public String toString() {
+        Map<String, GroovyRunner> map = getMap();
+        readLock.lock();
+        try {
+            return map.toString();
+        } finally {
+            readLock.unlock();
+        }
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/main/org/codehaus/groovy/plugin/GroovyRunner.java
----------------------------------------------------------------------
diff --git a/src/main/org/codehaus/groovy/plugin/GroovyRunner.java b/src/main/org/codehaus/groovy/plugin/GroovyRunner.java
index 5530716..40bd257 100644
--- a/src/main/org/codehaus/groovy/plugin/GroovyRunner.java
+++ b/src/main/org/codehaus/groovy/plugin/GroovyRunner.java
@@ -18,13 +18,11 @@
  */
 package org.codehaus.groovy.plugin;
 
-import groovy.lang.GroovyClassLoader;
-
 /**
  * Classes which can run scripts should implement this interface.
+ *
+ * @deprecated use {@link org.apache.groovy.plugin.GroovyRunner}
  */
-public interface GroovyRunner {
-    boolean canRun(Class scriptClass, GroovyClassLoader loader);
-
-    Object run(Class scriptClass, GroovyClassLoader loader);
+@Deprecated
+public interface GroovyRunner extends org.apache.groovy.plugin.GroovyRunner {
 }

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/main/org/codehaus/groovy/vmplugin/v5/JUnit4Utils.java
----------------------------------------------------------------------
diff --git a/src/main/org/codehaus/groovy/vmplugin/v5/JUnit4Utils.java b/src/main/org/codehaus/groovy/vmplugin/v5/JUnit4Utils.java
index 96318de..7d6afe8 100644
--- a/src/main/org/codehaus/groovy/vmplugin/v5/JUnit4Utils.java
+++ b/src/main/org/codehaus/groovy/vmplugin/v5/JUnit4Utils.java
@@ -17,7 +17,6 @@
  *  under the License.
  */
 package org.codehaus.groovy.vmplugin.v5;
-// TODO M12N move this to groovy-test
 
 import groovy.lang.GroovyClassLoader;
 import groovy.lang.GroovyRuntimeException;
@@ -29,7 +28,10 @@ import java.lang.reflect.Method;
 
 /**
  * Java 5 code for working with JUnit 4 tests.
+ *
+ * @deprecated use {@link org.apache.groovy.plugin.GroovyRunnerRegistry}
  */
+@Deprecated
 public class JUnit4Utils {
 
     /**

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/src/test/org/apache/groovy/plugin/GroovyRunnerRegistryTest.groovy
----------------------------------------------------------------------
diff --git a/src/test/org/apache/groovy/plugin/GroovyRunnerRegistryTest.groovy b/src/test/org/apache/groovy/plugin/GroovyRunnerRegistryTest.groovy
new file mode 100644
index 0000000..36b8cd7
--- /dev/null
+++ b/src/test/org/apache/groovy/plugin/GroovyRunnerRegistryTest.groovy
@@ -0,0 +1,192 @@
+/*
+ *  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.groovy.plugin
+
+import static java.util.Collections.emptyMap
+
+class GroovyRunnerRegistryTest extends GroovyTestCase {
+
+    Map<String, GroovyRunner> knownRunners = [
+            Junit3TestRunner: DefaultRunners.junit3TestRunner(),
+            Junit3SuiteRunner: DefaultRunners.junit3SuiteRunner(),
+            Junit4TestRunner: DefaultRunners.junit4TestRunner()
+    ]
+
+    GroovyRunnerRegistry registry = new GroovyRunnerRegistry(knownRunners)
+
+    void testServiceLoaderFindsKnownRunners() {
+        GroovyRunnerRegistry reg = GroovyRunnerRegistry.getInstance()
+        assert reg.@runnerMap == null
+        reg.load(null)
+        assert reg.@runnerMap.size() == 3
+        for (runner in reg) {
+            knownRunners.remove(runner.getClass().getSimpleName())
+        }
+        assert knownRunners.isEmpty()
+    }
+
+    void testCustomRunner() {
+        DummyRunner customRunner = new DummyRunner()
+        GroovyRunnerRegistry realRegistry = GroovyRunnerRegistry.getInstance()
+        realRegistry.put('DummyRunner', customRunner)
+        try {
+            def result = new GroovyShell().run('class DummyClass {}', 'DummyClass.groovy', [])
+            assert result == 'DummyClass was run'
+        } finally {
+            realRegistry.remove('DummyRunner')
+        }
+    }
+
+    void testLegacyCustomRunner() {
+        LegacyDummyRunner customRunner = new LegacyDummyRunner()
+        GroovyRunnerRegistry realRegistry = GroovyRunnerRegistry.getInstance()
+        realRegistry.put('LegacyDummyRunner', customRunner)
+        try {
+            def result = new GroovyShell().run('class LegacyDummyClass {}', 'LegacyDummyClass.groovy', [])
+            assert result == 'LegacyDummyClass was run'
+        } finally {
+            realRegistry.remove('LegacyDummyRunner')
+        }
+    }
+
+    void testSize() {
+        assert registry.size() == knownRunners.size()
+    }
+
+    void testIsEmpty() {
+        assert !registry.isEmpty()
+        assert new GroovyRunnerRegistry(emptyMap()).isEmpty()
+    }
+
+    void testContainsKey() {
+        assert registry.containsKey('Junit4TestRunner')
+        assert !registry.containsKey(null)
+    }
+
+    void testContainsValue() {
+        assert registry.containsValue(knownRunners.get('Junit4TestRunner'))
+        assert !registry.containsValue(null)
+    }
+
+    void testGet() {
+        assert registry.get('Junit4TestRunner') == knownRunners.get('Junit4TestRunner')
+        assert !registry.get(null)
+    }
+
+    void testPut() {
+        DummyRunner runner = new DummyRunner()
+        registry.put('DummyRunner', runner)
+
+        assert registry.get('DummyRunner').is(runner)
+        assert registry.put('DummyRunner', new DummyRunner()).is(runner)
+
+        assert !registry.put(null, runner)
+        assert !registry.put('DummyRunner', null)
+        assert !registry.put(null, null)
+    }
+
+    void testRemove() {
+        DummyRunner runner = new DummyRunner()
+        registry.put('DummyRunner', runner)
+
+        assert registry.remove('DummyRunner').is(runner)
+        assert !registry.remove('NotExistsRunner')
+        assert !registry.remove(null)
+    }
+
+    void testClear() {
+        assert registry.size() == knownRunners.size()
+        registry.clear()
+        assert registry.size() == 0
+    }
+
+    void testPutAll() {
+        shouldFail(NullPointerException) {
+            registry.putAll(null as Map<String, GroovyRunner>)
+        }
+
+        Map<String, GroovyRunner> map = ['Dummy': new DummyRunner(), 'Dummy2': null]
+        map[null] = new DummyRunner()
+
+        assert registry.size() == knownRunners.size()
+        registry.putAll(map)
+        assert registry.size() == knownRunners.size() + 1
+    }
+
+    void testAsIterable() {
+        Iterator itr = registry.iterator()
+        assert itr.hasNext()
+        assert itr.next() == knownRunners['Junit3TestRunner']
+        shouldFail(UnsupportedOperationException) {
+            itr.remove()
+        }
+    }
+
+    void testValues() {
+        Collection<GroovyRunner> values = registry.values()
+        assert values.size() == 3
+        assert values.contains(knownRunners['Junit3TestRunner'])
+        shouldFail(UnsupportedOperationException) {
+            values.remove(knownRunners['Junit3TestRunner'])
+        }
+    }
+
+    void testKeySet() {
+        Set<String> keySet = registry.keySet()
+        assert keySet.size() == 3
+        assert keySet.contains('Junit3TestRunner')
+        shouldFail(UnsupportedOperationException) {
+            keySet.remove('Junit3TestRunner')
+        }
+    }
+
+    void testEntrySet() {
+        Set<Map.Entry<String, GroovyRunner>> entries = registry.entrySet()
+        assert entries.size() == 3
+        assert entries.find { it.key == 'Junit3TestRunner' }
+        shouldFail(UnsupportedOperationException) {
+            entries.remove(entries.find { it.key == 'Junit3TestRunner' })
+        }
+    }
+
+    static class DummyRunner implements GroovyRunner {
+        @Override
+        boolean canRun(Class<?> scriptClass, GroovyClassLoader loader) {
+            return scriptClass.getSimpleName() == 'DummyClass'
+        }
+
+        @Override
+        Object run(Class<?> scriptClass, GroovyClassLoader loader) {
+            return 'DummyClass was run'
+        }
+    }
+
+    static class LegacyDummyRunner implements org.codehaus.groovy.plugin.GroovyRunner {
+        @Override
+        boolean canRun(Class<?> scriptClass, GroovyClassLoader loader) {
+            return scriptClass.getSimpleName() == 'LegacyDummyClass'
+        }
+
+        @Override
+        Object run(Class<?> scriptClass, GroovyClassLoader loader) {
+            return 'LegacyDummyClass was run'
+        }
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/subprojects/groovy-testng/build.gradle
----------------------------------------------------------------------
diff --git a/subprojects/groovy-testng/build.gradle b/subprojects/groovy-testng/build.gradle
index c359af0..20ebb90 100644
--- a/subprojects/groovy-testng/build.gradle
+++ b/subprojects/groovy-testng/build.gradle
@@ -22,4 +22,5 @@ dependencies {
         // exclude 'optional' beanshell even though testng's pom doesn't say optional
         exclude(group: 'org.beanshell', module: 'bsh')
     }
+    testCompile project(':groovy-test')
 }

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/subprojects/groovy-testng/src/main/java/org/apache/groovy/plugin/testng/TestNgRunner.java
----------------------------------------------------------------------
diff --git a/subprojects/groovy-testng/src/main/java/org/apache/groovy/plugin/testng/TestNgRunner.java b/subprojects/groovy-testng/src/main/java/org/apache/groovy/plugin/testng/TestNgRunner.java
new file mode 100644
index 0000000..e9f0b94
--- /dev/null
+++ b/subprojects/groovy-testng/src/main/java/org/apache/groovy/plugin/testng/TestNgRunner.java
@@ -0,0 +1,86 @@
+/*
+ *  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.groovy.plugin.testng;
+
+import groovy.lang.GroovyClassLoader;
+import groovy.lang.GroovyRuntimeException;
+import org.apache.groovy.plugin.GroovyRunner;
+import org.codehaus.groovy.runtime.InvokerHelper;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Method;
+
+/**
+ * Integration code for running TestNG tests in Groovy.
+ */
+public class TestNgRunner implements GroovyRunner {
+
+    /**
+     * Utility method to check via reflection if the parsed class appears to be a TestNG
+     * test, i.e. checks whether it appears to be using the relevant TestNG annotations.
+     *
+     * @param scriptClass the class we want to check
+     * @param loader the GroovyClassLoader to use to find classes
+     * @return true if the class appears to be a test
+     */
+    @Override
+    public boolean canRun(Class<?> scriptClass, GroovyClassLoader loader) {
+        try {
+            @SuppressWarnings("unchecked")
+            Class<? extends Annotation> testAnnotationClass =
+                    (Class<? extends Annotation>) loader.loadClass("org.testng.annotations.Test");
+            if (scriptClass.isAnnotationPresent(testAnnotationClass)) {
+                return true;
+            } else {
+                Method[] methods = scriptClass.getMethods();
+                for (Method method : methods) {
+                    if (method.isAnnotationPresent(testAnnotationClass)) {
+                        return true;
+                    }
+                }
+            }
+        } catch (Throwable e) {
+            // fall through
+        }
+        return false;
+    }
+
+    /**
+     * Utility method to run a TestNG test.
+     *
+     * @param scriptClass the class we want to run as a test
+     * @param loader the class loader to use
+     * @return the result of running the test
+     */
+    @Override
+    public Object run(Class<?> scriptClass, GroovyClassLoader loader) {
+        try {
+            Class<?> testNGClass = loader.loadClass("org.testng.TestNG");
+            Object testng = InvokerHelper.invokeConstructorOf(testNGClass, new Object[]{});
+            InvokerHelper.invokeMethod(testng, "setTestClasses", new Object[]{scriptClass});
+            Class<?> listenerClass = loader.loadClass("org.testng.TestListenerAdapter");
+            Object listener = InvokerHelper.invokeConstructorOf(listenerClass, new Object[]{});
+            InvokerHelper.invokeMethod(testng, "addListener", new Object[]{listener});
+            return InvokerHelper.invokeMethod(testng, "run", new Object[]{});
+        } catch (ClassNotFoundException e) {
+            throw new GroovyRuntimeException("Error running TestNG test.", e);
+        }
+    }
+
+}

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/subprojects/groovy-testng/src/main/java/org/codehaus/groovy/testng/TestNgRunner.java
----------------------------------------------------------------------
diff --git a/subprojects/groovy-testng/src/main/java/org/codehaus/groovy/testng/TestNgRunner.java b/subprojects/groovy-testng/src/main/java/org/codehaus/groovy/testng/TestNgRunner.java
index da49839..ecb7be6 100644
--- a/subprojects/groovy-testng/src/main/java/org/codehaus/groovy/testng/TestNgRunner.java
+++ b/subprojects/groovy-testng/src/main/java/org/codehaus/groovy/testng/TestNgRunner.java
@@ -18,77 +18,15 @@
  */
 package org.codehaus.groovy.testng;
 
-import groovy.lang.GroovyClassLoader;
-import groovy.lang.GroovyRuntimeException;
 import org.codehaus.groovy.plugin.GroovyRunner;
-import org.codehaus.groovy.runtime.InvokerHelper;
-
-import java.lang.annotation.Annotation;
-import java.lang.reflect.Method;
 
 /**
  * Integration code for running TestNG tests in Groovy.
+ *
+ * @deprecated use {@link org.apache.groovy.plugin.testng.TestNgRunner}
  */
-public class TestNgRunner implements GroovyRunner {
-
-    /**
-     * Utility method to check via reflection if the parsed class appears to be a TestNG
-     * test, i.e. checks whether it appears to be using the relevant TestNG annotations.
-     *
-     * @param scriptClass the class we want to check
-     * @param loader the GroovyClassLoader to use to find classes
-     * @return true if the class appears to be a test
-     */
-    @SuppressWarnings("unchecked")
-    public boolean canRun(Class scriptClass, GroovyClassLoader loader) {
-        // check if there are appropriate class or method annotations
-        // that suggest we have a TestNG test
-        boolean isTest = false;
-        try {
-            try {
-                Class testAnnotationClass = loader.loadClass("org.testng.annotations.Test");
-                Annotation annotation = scriptClass.getAnnotation(testAnnotationClass);
-                if (annotation != null) {
-                    isTest = true;
-                } else {
-                    Method[] methods = scriptClass.getMethods();
-                    for (Method method : methods) {
-                        annotation = method.getAnnotation(testAnnotationClass);
-                        if (annotation != null) {
-                            isTest = true;
-                            break;
-                        }
-                    }
-                }
-            } catch (ClassNotFoundException e) {
-                // fall through
-            }
-        } catch (Throwable e) {
-            // fall through
-        }
-        return isTest;
-    }
-
-    /**
-     * Utility method to run a TestNG test.
-     *
-     * @param scriptClass the class we want to run as a test
-     * @param loader the class loader to use
-     * @return the result of running the test
-     */
-    public Object run(Class scriptClass, GroovyClassLoader loader) {
-        // invoke through reflection to eliminate mandatory TestNG jar dependency
-        try {
-            Class testNGClass = loader.loadClass("org.testng.TestNG");
-            Object testng = InvokerHelper.invokeConstructorOf(testNGClass, new Object[]{});
-            InvokerHelper.invokeMethod(testng, "setTestClasses", new Object[]{scriptClass});
-            Class listenerClass = loader.loadClass("org.testng.TestListenerAdapter");
-            Object listener = InvokerHelper.invokeConstructorOf(listenerClass, new Object[]{});
-            InvokerHelper.invokeMethod(testng, "addListener", new Object[]{listener});
-            return InvokerHelper.invokeMethod(testng, "run", new Object[]{});
-        } catch (ClassNotFoundException e) {
-            throw new GroovyRuntimeException("Error running TestNG test.", e);
-        }
-    }
-
+@Deprecated
+public class TestNgRunner
+        extends org.apache.groovy.plugin.testng.TestNgRunner
+        implements GroovyRunner {
 }

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/subprojects/groovy-testng/src/main/resources/META-INF/services/org.apache.groovy.plugin.GroovyRunner
----------------------------------------------------------------------
diff --git a/subprojects/groovy-testng/src/main/resources/META-INF/services/org.apache.groovy.plugin.GroovyRunner b/subprojects/groovy-testng/src/main/resources/META-INF/services/org.apache.groovy.plugin.GroovyRunner
new file mode 100644
index 0000000..146b7e9
--- /dev/null
+++ b/subprojects/groovy-testng/src/main/resources/META-INF/services/org.apache.groovy.plugin.GroovyRunner
@@ -0,0 +1,20 @@
+#
+#  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.
+#
+
+org.apache.groovy.plugin.testng.TestNgRunner

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/subprojects/groovy-testng/src/main/resources/META-INF/services/org.codehaus.groovy.plugins.Runners
----------------------------------------------------------------------
diff --git a/subprojects/groovy-testng/src/main/resources/META-INF/services/org.codehaus.groovy.plugins.Runners b/subprojects/groovy-testng/src/main/resources/META-INF/services/org.codehaus.groovy.plugins.Runners
deleted file mode 100644
index 9505ad8..0000000
--- a/subprojects/groovy-testng/src/main/resources/META-INF/services/org.codehaus.groovy.plugins.Runners
+++ /dev/null
@@ -1,16 +0,0 @@
-# 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.
-
-org.codehaus.groovy.testng.TestNgRunner

http://git-wip-us.apache.org/repos/asf/groovy/blob/a2fb91d6/subprojects/groovy-testng/src/test/groovy/org/apache/groovy/plugin/testng/TestNgRunnerTest.groovy
----------------------------------------------------------------------
diff --git a/subprojects/groovy-testng/src/test/groovy/org/apache/groovy/plugin/testng/TestNgRunnerTest.groovy b/subprojects/groovy-testng/src/test/groovy/org/apache/groovy/plugin/testng/TestNgRunnerTest.groovy
new file mode 100644
index 0000000..0b23015
--- /dev/null
+++ b/subprojects/groovy-testng/src/test/groovy/org/apache/groovy/plugin/testng/TestNgRunnerTest.groovy
@@ -0,0 +1,41 @@
+/*
+ *  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.groovy.plugin.testng
+
+class TestNgRunnerTest extends GroovyShellTestCase {
+
+    void testRunWithTestNg() {
+        String test = '''
+            class F {
+                @org.testng.annotations.Test
+                void m() {
+                    org.testng.Assert.assertEquals(1,1)
+                }
+            }
+        '''
+        shell.run(test, 'F.groovy', [])
+    }
+
+    void testTestNgRunnerListedInRunnerList() {
+        assert shouldFail(GroovyRuntimeException) {
+            shell.run('class F {}', 'F.groovy', [])
+        }.contains('* ' + TestNgRunner.class.getName())
+    }
+
+}