diff --git a/core/modules/simpletest/css/simpletest.module.css b/core/modules/simpletest/css/simpletest.module.css
index 611cc4a..71f7eea 100644
--- a/core/modules/simpletest/css/simpletest.module.css
+++ b/core/modules/simpletest/css/simpletest.module.css
@@ -4,7 +4,7 @@
   width: 1em;
 }
 th.simpletest-test-label {
-  width: 16em;
+  width: 40%;
 }
 
 .simpletest-image {
diff --git a/core/modules/simpletest/simpletest.api.php b/core/modules/simpletest/simpletest.api.php
index dad9a39..e5251b7 100644
--- a/core/modules/simpletest/simpletest.api.php
+++ b/core/modules/simpletest/simpletest.api.php
@@ -14,9 +14,9 @@
  * Alter the list of tests.
  *
  * @param $groups
- *   A two dimension array, the first key is the test group (as defined in
- *   getInfo) the second is the name of the class and the value is the return
- *   value of the getInfo method.
+ *   A two dimensional array, the first key is the test group, the second is the
+ *   name of the test class, and the value is in associative array containing
+ *   'name', 'description', 'group', and 'requires' keys.
  */
 function hook_simpletest_alter(&$groups) {
   // An alternative session handler module would not want to run the original
diff --git a/core/modules/simpletest/simpletest.module b/core/modules/simpletest/simpletest.module
index 1b1a71c..1144e9d 100644
--- a/core/modules/simpletest/simpletest.module
+++ b/core/modules/simpletest/simpletest.module
@@ -6,6 +6,7 @@
 use Drupal\Core\Render\Element;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\simpletest\TestBase;
+use Drupal\simpletest\TestDiscovery;
 use Symfony\Component\Process\PhpExecutableFinder;
 
 /**
@@ -129,40 +130,34 @@ function _simpletest_format_summary_line($summary) {
  *
  * @param $test_list
  *   List of tests to run.
- * @param $reporter
- *   Which reporter to use. Allowed values are: text, xml, html and drupal,
- *   drupal being the default.
  *
  * @return string
  *   The test ID.
  */
-function simpletest_run_tests($test_list, $reporter = 'drupal') {
+function simpletest_run_tests($test_list) {
   $test_id = db_insert('simpletest_test_id')
     ->useDefaults(array('test_id'))
     ->execute();
 
-  $phpunit_tests = isset($test_list['UnitTest']) ? $test_list['UnitTest'] : array();
-  if ($phpunit_tests) {
+  if (!empty($test_list['phpunit'])) {
     $phpunit_results = simpletest_run_phpunit_tests($test_id, $phpunit_tests);
     simpletest_process_phpunit_results($phpunit_results);
   }
 
-  if (!array_key_exists('WebTest', $test_list) || empty($test_list['WebTest'])) {
-    // Early return if there are no WebTests to run.
+  // Early return if there are no further tests to run.
+  if (empty($test_list['simpletest'])) {
     return $test_id;
   }
 
-  // Contine with SimpleTests only.
-  $test_list = $test_list['WebTest'];
+  // Continue with SimpleTests only.
+  $test_list = $test_list['simpletest'];
 
   // Clear out the previous verbose files.
   file_unmanaged_delete_recursive('public://simpletest/verbose');
 
   // Get the info for the first test being run.
-  $first_test = array_shift($test_list);
-  $first_instance = new $first_test();
-  array_unshift($test_list, $first_test);
-  $info = $first_instance->getInfo();
+  $first_test = reset($test_list);
+  $info = TestDiscovery::getTestInfo(new \ReflectionClass($first_test));
 
   $batch = array(
     'title' => t('Running tests'),
@@ -336,7 +331,7 @@ function _simpletest_batch_operation($test_list_init, $test_id, &$context) {
   $test = new $test_class($test_id);
   $test->run();
   $size = count($test_list);
-  $info = $test->getInfo();
+  $info = TestDiscovery::getTestInfo(new \ReflectionClass($test));
 
   \Drupal::moduleHandler()->invokeAll('test_finished', array($test->results));
 
@@ -472,8 +467,8 @@ function simpletest_log_read($test_id, $database_prefix, $test_class) {
  *   returned.
  *
  * @return array[]
- *   An array of tests keyed with the groups specified in each of the tests'
- *   getInfo() methods and then keyed by the test classes. For example:
+ *   An array of tests keyed with the groups, and then keyed by test classes.
+ *   For example:
  *   @code
  *     $groups['Block'] => array(
  *       'BlockTestCase' => array(
@@ -485,159 +480,14 @@ function simpletest_log_read($test_id, $database_prefix, $test_class) {
  *   @endcode
  */
 function simpletest_test_get_all($module = NULL) {
-  static $all_groups = array();
-  $cid = "simpletest:$module";
-
-  if (!isset($all_groups[$cid])) {
-    $all_groups[$cid] = array();
-    $groups = &$all_groups[$cid];
-    // Register namespaces (extensions are not necessarily enabled).
-    simpletest_classloader_register();
-
-    // Load test information from cache if available, otherwise retrieve the
-    // information from each tests getInfo() method.
-    if ($cache = \Drupal::cache()->get($cid)) {
-      $groups = $cache->data;
-    }
-    else {
-      // Select all PSR-0 classes in the Tests namespace of all modules.
-      $listing = new ExtensionDiscovery();
-      // Ensure that tests in all profiles are discovered.
-      $listing->setProfileDirectories(array());
-      $all_data = $listing->scan('module', TRUE);
-      // If module is set then we keep only that one module.
-      if (isset($module)) {
-        $all_data = array(
-          $module => $all_data[$module],
-        );
-      }
-      else {
-        $all_data += $listing->scan('profile', TRUE);
-        $all_data += $listing->scan('theme', TRUE);
-      }
-
-      // Scan all extension folders for class files.
-      $classes = array();
-      foreach ($all_data as $name => $data) {
-
-        // Build the directory in which simpletest test classes would reside.
-        $tests_dir = DRUPAL_ROOT . '/' . $data->getPath() . '/src/Tests';
-
-        // Check if the directory exists.
-        if (!is_dir($tests_dir)) {
-          // This extension has no directory for simpletest cases.
-          continue;
-        }
-
-        // Scan the directory for class files.
-        $files = file_scan_directory($tests_dir, '/\.php$/');
-        if (empty($files)) {
-          // No class files found.
-          continue;
-        }
-
-        // Convert the file names into the namespaced class names.
-        $strlen = strlen($tests_dir) + 1;
-        $namespace = 'Drupal\\' . $name . '\Tests\\';
-        foreach ($files as $file) {
-          $classes[] = $namespace . str_replace('/', '\\', substr($file->uri, $strlen, -4));
-        }
-      }
-
-      // Check that each class has a getInfo() method and store the information
-      // in an array keyed with the group specified in the test information.
-      $groups = array();
-      foreach ($classes as $class) {
-        // Test classes need to implement getInfo() to be valid.
-        if (class_exists($class) && method_exists($class, 'getInfo')) {
-          $reflectionClass = new ReflectionClass($class);
-          // Skip abstract classes and interfaces.
-          if ($reflectionClass->isInstantiable()) {
-            $reflectionMethod = new ReflectionMethod($class, 'getInfo');
-            $declaringClass = $reflectionMethod->getDeclaringClass()->getName();
-            // Avoid testing intermediate classes which do not implement the
-            // method.
-            if ($class == $declaringClass) {
-              $info = call_user_func(array($class, 'getInfo'));
-            }
-            else {
-              continue;
-            }
-          }
-          else {
-            continue;
-          }
-          // If this test class requires a non-existing module, skip it.
-          if (!empty($info['dependencies'])) {
-            foreach ($info['dependencies'] as $dependency) {
-              if (!isset($dependency_data[$dependency])) {
-                continue 2;
-              }
-            }
-          }
-
-          $groups[$info['group']][$class] = $info;
-        }
-      }
-
-      // Sort the groups and tests within the groups by name.
-      uksort($groups, 'strnatcasecmp');
-      foreach ($groups as &$tests) {
-        uksort($tests, 'strnatcasecmp');
-      }
-
-      // Allow modules extending core tests to disable originals.
-      \Drupal::moduleHandler()->alter('simpletest', $groups);
-      \Drupal::cache()->set($cid, $groups);
-    }
-  }
-  return $all_groups[$cid];
+  return \Drupal::service('test_discovery')->getTestClasses($module);
 }
 
 /**
  * Registers namespaces for disabled modules.
  */
 function simpletest_classloader_register() {
-  // Use the same cache prefix as simpletest_test_get_all().
-  $cid = "simpletest::all";
-  $types = array(
-    'theme_engine',
-    'module',
-    'theme',
-    'profile',
-  );
-
-  if ($cache = \Drupal::cache()->get($cid)) {
-    $extensions = $cache->data;
-  }
-  else {
-    $listing = new ExtensionDiscovery();
-    // Ensure that tests in all profiles are discovered.
-    $listing->setProfileDirectories(array());
-    $extensions = array();
-    foreach ($types as $type) {
-      foreach ($listing->scan($type, TRUE) as $name => $file) {
-        $extensions[$type][$name] = $file->getPathname();
-      }
-    }
-    \Drupal::cache()->set($cid, $extensions);
-  }
-
-  $classloader = drupal_classloader();
-  foreach ($types as $type) {
-    foreach ($extensions[$type] as $name => $uri) {
-      drupal_classloader_register($name, dirname($uri));
-      $classloader->addPsr4('Drupal\\' . $name . '\\Tests\\', array(
-        DRUPAL_ROOT . '/' . dirname($uri) . '/tests/Drupal/' . $name . '/Tests',
-        DRUPAL_ROOT . '/' . dirname($uri) . '/tests/src',
-      ));
-      // While being there, prime drupal_get_filename().
-      drupal_get_filename($type, $name, $uri);
-    }
-  }
-
-  // Register the core test directory so we can find \Drupal\UnitTestCase.
-  $classloader->add('Drupal\\Tests', DRUPAL_ROOT . '/core/tests');
+  \Drupal::service('test_discovery')->registerTestNamespaces();
 }
 
 /**
@@ -798,60 +648,6 @@ function simpletest_mail_alter(&$message) {
 }
 
 /**
- * Gets PHPUnit classes.
- *
- * @param string $module
- *   Name of a module. If set then only tests belonging to this module is
- *   returned.
- *
- * @return array
- *   Returns an array of test classes.
- *
- * @throws \RuntimeException
- *   This is thrown when anything is wrong with a test.
- */
-function simpletest_phpunit_get_available_tests($module = NULL) {
-  // Try to load the class names array from cache.
-  $cid = 'simpletest_phpunit:' . $module;
-  if ($cache = \Drupal::cache()->get($cid)) {
-    $test_classes = $cache->data;
-  }
-  else {
-    if ($module) {
-      $prefix = 'Drupal\\' . $module . '\\';
-      $n = strlen($prefix);
-    }
-    // If there was no cached data available we have to find the tests.
-    // Load the PHPUnit configuration file, which tells us where to find the
-    // tests.
-    $phpunit_config = simpletest_phpunit_configuration_filepath();
-    $configuration = PHPUnit_Util_Configuration::getInstance($phpunit_config);
-    // Find all the tests and get a list of unique class names.
-    $test_suite = $configuration->getTestSuiteConfiguration(NULL);
-    $test_classes = array();
-    /** @var $test_suite \PHPUnit_Framework_TestSuite[] */
-    foreach ($test_suite as $test) {
-      // PHPUnit returns a warning message if something is wrong with a test,
-      // throw an exception to avoid an error when trying to call getInfo() on
-      // this.
-      if ($test instanceof PHPUnit_Framework_Warning) {
-        throw new RuntimeException($test->getMessage());
-      }
-
-      $name = $test->getName();
-      if (!array_key_exists($name, $test_classes) && (!$module || substr($name, 0, $n) == $prefix)) {
-        $test_classes[$name] = $name::getInfo();
-      }
-    }
-
-    // Since we have recalculated, we now need to store the new data into cache.
-    \Drupal::cache()->set($cid, $test_classes);
-  }
-
-  return $test_classes;
-}
-
-/**
  * Converts PHPUnit's JUnit XML output to an array.
  *
  * @param $test_id
diff --git a/core/modules/simpletest/simpletest.services.yml b/core/modules/simpletest/simpletest.services.yml
new file mode 100644
index 0000000..56d48ca
--- /dev/null
+++ b/core/modules/simpletest/simpletest.services.yml
@@ -0,0 +1,4 @@
+services:
+  test_discovery:
+    class: Drupal\simpletest\TestDiscovery
+    arguments: ['@class_loader', '@?cache.discovery']
diff --git a/core/modules/simpletest/src/Form/SimpletestResultsForm.php b/core/modules/simpletest/src/Form/SimpletestResultsForm.php
index 7c55197..3889ee1 100644
--- a/core/modules/simpletest/src/Form/SimpletestResultsForm.php
+++ b/core/modules/simpletest/src/Form/SimpletestResultsForm.php
@@ -9,6 +9,7 @@
 
 use Drupal\Core\Database\Connection;
 use Drupal\Core\Form\FormBase;
+use Drupal\simpletest\TestDiscovery;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
@@ -148,7 +149,7 @@ public function buildForm(array $form, array &$form_state, $test_id = NULL) {
     $form['result']['results'] = array();
     foreach ($results as $group => $assertions) {
       // Create group details with summary information.
-      $info = call_user_func(array($group, 'getInfo'));
+      $info = TestDiscovery::getTestInfo(new \ReflectionClass($group));
       $form['result']['results'][$group] = array(
         '#type' => 'details',
         '#title' => $info['name'],
diff --git a/core/modules/simpletest/src/Form/SimpletestTestForm.php b/core/modules/simpletest/src/Form/SimpletestTestForm.php
index e3876a5..ab9b55e 100644
--- a/core/modules/simpletest/src/Form/SimpletestTestForm.php
+++ b/core/modules/simpletest/src/Form/SimpletestTestForm.php
@@ -27,6 +27,20 @@ public function getFormId() {
    * {@inheritdoc}
    */
   public function buildForm(array $form, array &$form_state) {
+    $form['actions'] = array('#type' => 'actions');
+    $form['actions']['submit'] = array(
+      '#type' => 'submit',
+      '#value' => $this->t('Run tests'),
+      '#tableselect' => TRUE,
+      '#button_type' => 'primary',
+    );
+
+    // Do not needlessly re-execute a full test discovery if the user input
+    // already contains an explicit list of test classes to run.
+    if (!empty($form_state['input']['tests'])) {
+      return $form;
+    }
+
     // JavaScript-only table filters.
     $form['filters'] = array(
       '#type' => 'container',
@@ -96,9 +110,6 @@ public function buildForm(array $form, array &$form_state) {
 
     // Generate the list of tests arranged by group.
     $groups = simpletest_test_get_all();
-    $groups['PHPUnit'] = simpletest_phpunit_get_available_tests();
-    $form_state['storage']['PHPUnit'] = $groups['PHPUnit'];
-
     foreach ($groups as $group => $tests) {
       $form['tests'][$group] = array(
         '#attributes' => array('class' => array('simpletest-group')),
@@ -131,11 +142,6 @@ public function buildForm(array $form, array &$form_state) {
         ),
       );
 
-      // Sort test classes within group alphabetically by name/label.
-      uasort($tests, function ($a, $b) {
-        return SortArray::sortByKeyString($a, $b, 'name');
-      });
-
       // Cycle through each test within the current group.
       foreach ($tests as $class => $info) {
         $form['tests'][$class] = array(
@@ -150,10 +156,7 @@ public function buildForm(array $form, array &$form_state) {
         );
         $form['tests'][$class]['description'] = array(
           '#prefix' => '<div class="description">',
-          '#markup' => String::format('@description (@class)', array(
-            '@description' => $info['description'],
-            '@class' => $class,
-          )),
+          '#markup' => String::checkPlain($info['description']),
           '#suffix' => '</div>',
           '#wrapper_attributes' => array(
             'class' => array('simpletest-test-description', 'table-filter-text-source'),
@@ -162,15 +165,6 @@ public function buildForm(array $form, array &$form_state) {
       }
     }
 
-    // Action buttons.
-    $form['actions'] = array('#type' => 'actions');
-    $form['actions']['submit'] = array(
-      '#type' => 'submit',
-      '#value' => $this->t('Run tests'),
-      '#tableselect' => TRUE,
-      '#button_type' => 'primary',
-    );
-
     $form['clean'] = array(
       '#type' => 'fieldset',
       '#title' => $this->t('Clean test environment'),
@@ -190,16 +184,31 @@ public function buildForm(array $form, array &$form_state) {
    * {@inheritdoc}
    */
   public function submitForm(array &$form, array &$form_state) {
+    // Test discovery does not run upon form submission.
     simpletest_classloader_register();
 
-    $phpunit_all = array_keys($form_state['storage']['PHPUnit']);
+    // This form accepts arbitrary user input for 'tests'.
+    // An invalid value will cause the $class_name lookup below to die with a
+    // fatal error. Regular user access mechanisms to this form are intact.
+    // The only validation effectively being skipped is the validation of
+    // available checkboxes vs. submitted checkboxes.
+    // @todo Refactor Form API to allow to POST values without constructing the
+    //   entire form more easily, BUT retaining routing access security and
+    //   retaining Form API CSRF #token security validation, and without having
+    //   to rely on form caching.
+    if (empty($form_state['values']['tests']) && !empty($form_state['input']['tests'])) {
+      $form_state['values']['tests'] = $form_state['input']['tests'];
+    }
 
     $tests_list = array();
     foreach ($form_state['values']['tests'] as $class_name => $value) {
-      // Since class_exists() will likely trigger an autoload lookup,
-      // we do the fast check first.
-      if ($value === $class_name && class_exists($class_name)) {
-        $test_type = in_array($class_name, $phpunit_all) ? 'UnitTest' : 'WebTest';
+      if ($value === $class_name) {
+        if (is_subclass_of($class_name, 'PHPUnit_Framework_TestCase')) {
+          $test_type = 'phpunit';
+        }
+        else {
+          $test_type = 'simpletest';
+        }
         $tests_list[$test_type][] = $class_name;
       }
     }
diff --git a/core/modules/simpletest/src/TestBase.php b/core/modules/simpletest/src/TestBase.php
index dd583f5..a48cd6f 100644
--- a/core/modules/simpletest/src/TestBase.php
+++ b/core/modules/simpletest/src/TestBase.php
@@ -212,27 +212,6 @@ public function __construct($test_id = NULL) {
   }
 
   /**
-   * Provides meta information about this test case, such as test name.
-   *
-   * @return array
-   *   An array of untranslated strings with the following keys:
-   *   - name: An overview of what is tested by the class; for example, "User
-   *     access rules".
-   *   - description: One sentence describing the test, starting with a verb.
-   *   - group: The human-readable name of the module ("Node", "Statistics"), or
-   *     the human-readable name of the Drupal facility tested (e.g. "Form API"
-   *     or "XML-RPC").
-   */
-  public static function getInfo() {
-    // PHP does not allow us to declare this method as abstract public static,
-    // so we simply throw an exception here if this has not been implemented by
-    // a child class.
-    throw new \RuntimeException(String::format('@class must implement \Drupal\simpletest\TestBase::getInfo().', array(
-      '@class' => get_called_class(),
-    )));
-  }
-
-  /**
    * Performs setup tasks before each individual test method is run.
    */
   abstract protected function setUp();
diff --git a/core/modules/simpletest/src/TestDiscovery.php b/core/modules/simpletest/src/TestDiscovery.php
new file mode 100644
index 0000000..144e2a4
--- /dev/null
+++ b/core/modules/simpletest/src/TestDiscovery.php
@@ -0,0 +1,453 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\simpletest\TestDiscovery.
+ */
+
+namespace Drupal\simpletest;
+
+use Composer\Autoload\ClassLoader;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Extension\Extension;
+use Drupal\Core\Extension\ExtensionDiscovery;
+use PHPUnit_Util_Test;
+
+/**
+ * Discovers available tests.
+ */
+class TestDiscovery {
+
+  /**
+   * The class loader.
+   *
+   * @var \Composer\Autoload\ClassLoader
+   */
+  protected $classLoader;
+
+  /**
+   * Backend for caching discovery results.
+   *
+   * @var \Drupal\Core\Cache\CacheBackendInterface
+   */
+  protected $cacheBackend;
+
+  /**
+   * Cached map of all test namespaces to respective directories.
+   *
+   * @var array
+   */
+  protected $testNamespaces;
+
+  /**
+   * Cached list of all available extension names, keyed by extension type.
+   *
+   * @var array
+   */
+  protected $availableExtensions;
+
+  /**
+   * Constructs a new test discovery.
+   *
+   * @param \Composer\Autoload\ClassLoader $class_loader
+   *   The class loader.
+   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
+   *   (optional) Backend for caching discovery results.
+   */
+  public function __construct(ClassLoader $class_loader, CacheBackendInterface $cache_backend = NULL) {
+    $this->classLoader = $class_loader;
+    $this->cacheBackend = $cache_backend;
+  }
+
+  /**
+   * Registers test namespaces of all available extensions.
+   *
+   * @return array
+   *   An associative array whose keys are PSR-4 namespace prefixes and whose
+   *   values are directory names.
+   */
+  public function registerTestNamespaces() {
+    if (isset($this->testNamespaces)) {
+      return $this->testNamespaces;
+    }
+    $this->testNamespaces = array();
+
+    $existing = $this->classLoader->getPrefixesPsr4();
+
+    // Add PHPUnit test namespace of Drupal core.
+    $this->testNamespaces['Drupal\\Tests\\'] = [DRUPAL_ROOT . '/core/tests/Drupal/Tests'];
+
+    $this->availableExtensions = array();
+    foreach ($this->getExtensions() as $name => $extension) {
+      $this->availableExtensions[$extension->getType()][$name] = $name;
+
+      $base_namespace = "Drupal\\$name\\";
+      $base_path = DRUPAL_ROOT . '/' . $extension->getPath();
+
+      // Add namespace of disabled/uninstalled extensions.
+      if (!isset($existing[$base_namespace])) {
+        $this->classLoader->addPsr4($base_namespace, "$base_path/src");
+      }
+      // Add Simpletest test namespace.
+      $this->testNamespaces[$base_namespace . 'Tests\\'][] = "$base_path/src/Tests";
+
+      // Add PHPUnit test namespace.
+      // @todo Move PHPUnit namespace of extensions into Drupal\Tests\$name.
+      // @see https://www.drupal.org/node/2260121
+      $this->testNamespaces[$base_namespace . 'Tests\\'][] = "$base_path/tests/src";
+    }
+
+    foreach ($this->testNamespaces as $prefix => $paths) {
+      $this->classLoader->addPsr4($prefix, $paths);
+    }
+
+    return $this->testNamespaces;
+  }
+
+  /**
+   * Discovers all available tests in all extensions.
+   *
+   * @param string $extension
+   *   (optional) The name of an extension to limit discovery to; e.g., 'node'.
+   *
+   * @return array
+   *   An array of tests keyed by the first @group specified in each test's
+   *   PHPDoc comment block, and then keyed by class names. For example:
+   *   @code
+   *     $groups['block'] => array(
+   *       'Drupal\block\Tests\BlockTest' => array(
+   *         'name' => 'Drupal\block\Tests\BlockTest',
+   *         'description' => 'Tests block UI CRUD functionality.',
+   *         'group' => 'block',
+   *       ),
+   *     );
+   *   @endcode
+   *
+   * @throws \ReflectionException
+   *   If a discovered test class does not match the expected class name.
+   *
+   * @todo Remove singular grouping; retain list of groups in 'group' key.
+   * @see https://www.drupal.org/node/2296615
+   * @todo Add base class groups 'Kernel' + 'Web', complementing 'PHPUnit'.
+   */
+  public function getTestClasses($extension = NULL) {
+    if (!isset($extension)) {
+      if ($this->cacheBackend && $cache = $this->cacheBackend->get('simpletest:discovery:classes')) {
+        return $cache->data;
+      }
+    }
+    $list = array();
+
+    $classmap = $this->findAllClassFiles($extension);
+
+    // Prevent expensive class loader lookups for each reflected test class by
+    // registering the complete classmap of test classes to the class loader.
+    // This also ensures that test classes are loaded from the discovered
+    // pathnames; a namespace/classname mismatch will throw an exception.
+    $this->classLoader->addClassMap($classmap);
+
+    foreach ($classmap as $classname => $pathname) {
+      try {
+        $class = new \ReflectionClass($classname);
+      }
+      catch (\ReflectionException $e) {
+        // Re-throw with expected pathname.
+        $message = $e->getMessage() . " in expected $pathname";
+        throw new \ReflectionException($message, $e->getCode(), $e);
+      }
+      // Skip interfaces, abstract classes, and traits.
+      if (!$class->isInstantiable()) {
+        continue;
+      }
+      // Skip non-test classes.
+      if (!$class->isSubclassOf('Drupal\simpletest\TestBase') && !$class->isSubclassOf('PHPUnit_Framework_TestCase')) {
+        continue;
+      }
+      $info = static::getTestInfo($class);
+
+      // Skip this test class if it requires unavailable modules.
+      // @todo PHPUnit skips tests with unmet requirements when executing a test
+      //   (instead of excluding them upfront). Refactor test runner to follow
+      //   that approach.
+      // @see https://www.drupal.org/node/1273478
+      if (!empty($info['requires']['module'])) {
+        if (array_diff($info['requires']['module'], $this->availableExtensions['module'])) {
+          continue;
+        }
+      }
+
+      $list[$info['group']][$classname] = $info;
+    }
+
+    // Sort the groups and tests within the groups by name.
+    uksort($list, 'strnatcasecmp');
+    foreach ($list as &$tests) {
+      uksort($tests, 'strnatcasecmp');
+    }
+
+    // Allow modules extending core tests to disable originals.
+    \Drupal::moduleHandler()->alter('simpletest', $list);
+
+    if (!isset($extension)) {
+      if ($this->cacheBackend) {
+        $this->cacheBackend->set('simpletest:discovery:classes', $list);
+      }
+    }
+    return $list;
+  }
+
+  /**
+   * Discovers all class files in all available extensions.
+   *
+   * @param string $extension
+   *   (optional) The name of an extension to limit discovery to; e.g., 'node'.
+   *
+   * @return array
+   *   A classmap containing all discovered class files; i.e., a map of
+   *   fully-qualified classnames to pathnames.
+   */
+  public function findAllClassFiles($extension = NULL) {
+    $classmap = array();
+    $namespaces = $this->registerTestNamespaces();
+    if (isset($extension)) {
+      $namespaces = array_intersect_key($namespaces, array_flip(preg_grep('/' . preg_quote("Drupal\\$extension\\", '/') . '/', array_keys($namespaces))));
+    }
+    foreach ($namespaces as $namespace => $paths) {
+      foreach ($paths as $path) {
+        if (!is_dir($path)) {
+          continue;
+        }
+        $classmap += static::scanDirectory($namespace, $path);
+      }
+    }
+    return $classmap;
+  }
+
+  /**
+   * Scans a given directory for class files.
+   *
+   * @param string $namespace_prefix
+   *   The namespace prefix to use for discovered classes. Must contain a
+   *   trailing namespace separator (backslash).
+   *   For example: 'Drupal\\node\\Tests\\'
+   * @param string $path
+   *   The directory path to scan.
+   *   For example: '/path/to/drupal/core/modules/node/tests/src'
+   *
+   * @return array
+   *   An associative array whose keys are fully-qualified class names and whose
+   *   values are corresponding filesystem pathnames.
+   *
+   * @throws \InvalidArgumentException
+   *   If $namespace_prefix does not end in a namespace separator (backslash).
+   *
+   * @todo Limit to '*Test.php' files (~10% less files to reflect/introspect).
+   * @see https://www.drupal.org/node/2296635
+   */
+  public static function scanDirectory($namespace_prefix, $path) {
+    if (substr($namespace_prefix, -1) !== '\\') {
+      throw new \InvalidArgumentException("Namespace prefix for $path must contain a trailing namespace separator.");
+    }
+    $flags = \FilesystemIterator::UNIX_PATHS;
+    $flags |= \FilesystemIterator::SKIP_DOTS;
+    $flags |= \FilesystemIterator::FOLLOW_SYMLINKS;
+    $flags |= \FilesystemIterator::CURRENT_AS_SELF;
+
+    $iterator = new \RecursiveDirectoryIterator($path, $flags);
+    $filter = new \RecursiveCallbackFilterIterator($iterator, function ($current, $key, $iterator) {
+      if ($iterator->hasChildren()) {
+        return TRUE;
+      }
+      return $current->isFile() && $current->getExtension() === 'php';
+    });
+    $files = new \RecursiveIteratorIterator($filter);
+    $classes = array();
+    foreach ($files as $fileinfo) {
+      $class = $namespace_prefix;
+      if ('' !== $subpath = $fileinfo->getSubPath()) {
+        $class .= strtr($subpath, '/', '\\') . '\\';
+      }
+      $class .= $fileinfo->getBasename('.php');
+      $classes[$class] = $fileinfo->getPathname();
+    }
+    return $classes;
+  }
+
+  /**
+   * Retrieves information about a test class for UI purposes.
+   *
+   * @param \ReflectionClass $class
+   *   The reflected test class.
+   *
+   * @return array
+   *   An associative array containing:
+   *   - name: The test class name.
+   *   - description: The test (PHPDoc) summary.
+   *   - group: The test's first @group (parsed from PHPDoc annotations).
+   *   - requires: An associative array containing test requirements parsed from
+   *     PHPDoc annotations:
+   *     - module: List of Drupal module extension names the test depends on.
+   *
+   * @throws \LogicException
+   *   If the class does not have a PHPDoc summary line or @coversDefaultClass
+   *   annotation.
+   * @throws \LogicException
+   *   If the class does not have a @group annotation.
+   */
+  public static function getTestInfo(\ReflectionClass $class) {
+    $classname = $class->getName();
+    $info = array(
+      'name' => $classname,
+    );
+
+    // Automatically convert @coversDefaultClass into summary.
+    $annotations = static::parseTestClassAnnotations($class);
+    if (isset($annotations['coversDefaultClass'][0])) {
+      $info['description'] = 'Tests ' . $annotations['coversDefaultClass'][0] . '.';
+    }
+    elseif ($summary = static::parseTestClassSummary($class)) {
+      $info['description'] = $summary;
+    }
+    else {
+      throw new \LogicException(sprintf('Missing PHPDoc summary line on %s in %s.', $classname, $class->getFileName()));
+    }
+
+    // Reduce to @group and @requires.
+    $info += array_intersect_key($annotations, array('group' => 1, 'requires' => 1));
+
+    // @todo Remove legacy getInfo() methods.
+    if (method_exists($classname, 'getInfo')) {
+      $legacy_info = $classname::getInfo();
+
+      // Derive the primary @group from the namespace to ensure that legacy
+      // tests are not located in different groups than converted tests.
+      $classparts = explode('\\', $classname);
+      if ($classparts[1] === 'Tests') {
+        if ($classparts[2] === 'Component' || $classparts[2] === 'Core') {
+          // Drupal\Tests\Component\{group}\...
+          $info['group'][] = $classparts[3];
+        }
+        else {
+          // Drupal\Tests\{group}\...
+          // @see https://www.drupal.org/node/2260121
+          $info['group'][] = $classparts[2];
+        }
+      }
+      elseif ($classparts[1] === 'system' && $classparts[3] !== 'System') {
+        // Drupal\system\Tests\{group}\...
+        $info['group'][] = $classparts[3];
+      }
+      else {
+        // Drupal\{group}\Tests\...
+        $info['group'][] = $classparts[1];
+      }
+
+      if (isset($legacy_info['dependencies'])) {
+        $info += array('requires' => array());
+        $info['requires'] += array('module' => array());
+        $info['requires']['module'] = array_merge($info['requires']['module'], $legacy_info['dependencies']);
+      }
+    }
+
+    // Process @group information.
+    // @todo Support multiple @groups + change UI to expose a group select
+    //   dropdown to filter tests by group instead of collapsible table rows.
+    // @see https://www.drupal.org/node/2296615
+    // @todo Replace single enforced PHPUnit group with base class groups.
+    if ($class->isSubclassOf('PHPUnit_Framework_TestCase')) {
+      $info['group'] = 'PHPUnit';
+    }
+    else {
+      if (empty($info['group'])) {
+        throw new \LogicException("Missing @group for $classname.");
+      }
+      $info['group'] = reset($info['group']);
+    }
+
+    return $info;
+  }
+
+  /**
+   * Parses the phpDoc summary line of a test class.
+   *
+   * @param \ReflectionClass $class
+   *   The reflected test class.
+   *
+   * @return string
+   *   The parsed phpDoc summary line.
+   */
+  public static function parseTestClassSummary(\ReflectionClass $class) {
+    $phpDoc = $class->getDocComment();
+    // Normalize line endings.
+    $phpDoc = preg_replace('/\r\n|\r/', '\n', $phpDoc);
+    // Strip leading and trailing doc block lines.
+    //$phpDoc = trim($phpDoc, "* /\n");
+    $phpDoc = substr($phpDoc, 4, -4);
+
+    // Extract actual phpDoc content.
+    $phpDoc = explode("\n", $phpDoc);
+    array_walk($phpDoc, function (&$value) {
+      $value = trim($value, "* /\n");
+    });
+
+    // Extract summary; allowed to it wrap and continue on next line.
+    list($summary) = explode("\n\n", implode("\n", $phpDoc));
+    if ($summary === '') {
+      throw new \LogicException(sprintf('Missing phpDoc on %s.', $class->getName()));
+    }
+    return $summary;
+  }
+
+  /**
+   * Parses annotations in the phpDoc of a test class.
+   *
+   * @param \ReflectionClass $class
+   *   The reflected test class.
+   *
+   * @return array
+   *   An associative array that contains all annotations on the test class;
+   *   typically including:
+   *   - group: A list of @group values.
+   *   - requires: An associative array of @requires values; e.g.:
+   *     - module: A list of Drupal module dependencies that are required to
+   *       exist.
+   *
+   * @see PHPUnit_Util_Test::parseTestMethodAnnotations()
+   * @see http://phpunit.de/manual/current/en/incomplete-and-skipped-tests.html#incomplete-and-skipped-tests.skipping-tests-using-requires
+   */
+  public static function parseTestClassAnnotations(\ReflectionClass $class) {
+    $annotations = PHPUnit_Util_Test::parseTestMethodAnnotations($class->getName())['class'];
+
+    // @todo Enhance PHPUnit upstream to allow for custom @requires identifiers.
+    // @see PHPUnit_Util_Test::getRequirements()
+    // @todo Add support for 'PHP', 'OS', 'function', 'extension'.
+    // @see https://www.drupal.org/node/1273478
+    if (isset($annotations['requires'])) {
+      foreach ($annotations['requires'] as $i => $value) {
+        list($type, $value) = explode(' ', $value, 2);
+        if ($type === 'module') {
+          $annotations['requires']['module'][$value] = $value;
+          unset($annotations['requires'][$i]);
+        }
+      }
+    }
+    return $annotations;
+  }
+
+  /**
+   * Returns all available extensions.
+   *
+   * @return \Drupal\Core\Extension\Extension[]
+   *   An array of Extension objects, keyed by extension name.
+   */
+  protected function getExtensions() {
+    $listing = new ExtensionDiscovery();
+    // Ensure that tests in all profiles are discovered.
+    $listing->setProfileDirectories(array());
+    $extensions = $listing->scan('module', TRUE);
+    $extensions += $listing->scan('profile', TRUE);
+    $extensions += $listing->scan('theme', TRUE);
+    return $extensions;
+  }
+
+}
diff --git a/core/modules/simpletest/src/Tests/InstallationProfileModuleTestsTest.php b/core/modules/simpletest/src/Tests/InstallationProfileModuleTestsTest.php
index 994fb36..5a4dfcf 100644
--- a/core/modules/simpletest/src/Tests/InstallationProfileModuleTestsTest.php
+++ b/core/modules/simpletest/src/Tests/InstallationProfileModuleTestsTest.php
@@ -55,7 +55,7 @@ function setUp() {
    */
   function testInstallationProfileTests() {
     $this->drupalGet('admin/config/development/testing');
-    $this->assertText('Installation profile module tests helper');
+    $this->assertText('Drupal\drupal_system_listing_compatible_test\Tests\SystemListingCompatibleTest');
     $edit = array(
       'tests[Drupal\drupal_system_listing_compatible_test\Tests\SystemListingCompatibleTest]' => TRUE,
     );
diff --git a/core/modules/simpletest/src/Tests/OtherInstallationProfileTestsTest.php b/core/modules/simpletest/src/Tests/OtherInstallationProfileTestsTest.php
index aa848e1..76bf168 100644
--- a/core/modules/simpletest/src/Tests/OtherInstallationProfileTestsTest.php
+++ b/core/modules/simpletest/src/Tests/OtherInstallationProfileTestsTest.php
@@ -63,8 +63,7 @@ function testOtherInstallationProfile() {
 
     // Assert the existence of a test for a module in a different installation
     // profile than the current.
-    $this->drupalGet('admin/config/development/testing');
-    $this->assertText('Installation profile module tests helper');
+    $this->assertText('Drupal\drupal_system_listing_compatible_test\Tests\SystemListingCompatibleTest');
   }
 
 }
diff --git a/core/modules/simpletest/src/Tests/SimpleTestTest.php b/core/modules/simpletest/src/Tests/SimpleTestTest.php
index 9d16df3..b0dea1f 100644
--- a/core/modules/simpletest/src/Tests/SimpleTestTest.php
+++ b/core/modules/simpletest/src/Tests/SimpleTestTest.php
@@ -369,9 +369,8 @@ function getTestResults() {
    */
   function getResultFieldSet() {
     $all_details = $this->xpath('//details');
-    $info = $this->getInfo();
     foreach ($all_details as $details) {
-      if ($this->asText($details->summary) == $info['name']) {
+      if ($this->asText($details->summary) == __CLASS__) {
         return $details;
       }
     }
diff --git a/core/scripts/run-tests.sh b/core/scripts/run-tests.sh
index 4679325..88ba503 100755
--- a/core/scripts/run-tests.sh
+++ b/core/scripts/run-tests.sh
@@ -57,11 +57,11 @@
   // Display all available tests.
   echo "\nAvailable test groups & classes\n";
   echo   "-------------------------------\n\n";
-  $groups = simpletest_script_get_all_tests();
+  $groups = simpletest_test_get_all($args['module']);
   foreach ($groups as $group => $tests) {
     echo $group . "\n";
     foreach ($tests as $class => $info) {
-      echo " - " . $info['name'] . ' (' . $class . ')' . "\n";
+      echo " - $class\n";
     }
   }
   exit;
@@ -497,34 +497,6 @@ function simpletest_script_setup_database($new = FALSE) {
 }
 
 /**
- * Get all available tests from simpletest and PHPUnit.
- *
- * @param string $module
- *   Name of a module. If set then only tests belonging to this module are
- *   returned.
- *
- * @return
- *   An array of tests keyed with the groups specified in each of the tests
- *   getInfo() method and then keyed by the test class. An example of the array
- *   structure is provided below.
- *
- *   @code
- *     $groups['Block'] => array(
- *       'BlockTestCase' => array(
- *         'name' => 'Block functionality',
- *         'description' => 'Add, edit and delete custom block...',
- *         'group' => 'Block',
- *       ),
- *     );
- *   @endcode
- */
-function simpletest_script_get_all_tests($module = NULL) {
-  $tests = simpletest_test_get_all($module);
-  $tests['PHPUnit'] = simpletest_phpunit_get_available_tests($module);
-  return $tests;
-}
-
-/**
  * Execute a batch of tests.
  */
 function simpletest_script_execute_batch($test_classes) {
@@ -786,7 +758,7 @@ function simpletest_script_get_test_list() {
 
   $test_list = array();
   if ($args['all'] || $args['module']) {
-    $groups = simpletest_script_get_all_tests($args['module']);
+    $groups = simpletest_test_get_all($args['module']);
     $all_tests = array();
     foreach ($groups as $group => $tests) {
       $all_tests = array_merge($all_tests, array_keys($tests));
@@ -829,7 +801,7 @@ function simpletest_script_get_test_list() {
       }
     }
     else {
-      $groups = simpletest_script_get_all_tests();
+      $groups = simpletest_test_get_all();
       foreach ($args['test_names'] as $group_name) {
         $test_list = array_merge($test_list, array_keys($groups[$group_name]));
       }
diff --git a/core/tests/Drupal/Tests/UnitTestCase.php b/core/tests/Drupal/Tests/UnitTestCase.php
index adc7569..35ed4e3 100644
--- a/core/tests/Drupal/Tests/UnitTestCase.php
+++ b/core/tests/Drupal/Tests/UnitTestCase.php
@@ -27,27 +27,6 @@
   protected $randomGenerator;
 
   /**
-   * Provides meta information about this test case, such as test name.
-   *
-   * @return array
-   *   An array of untranslated strings with the following keys:
-   *   - name: An overview of what is tested by the class; for example, "User
-   *     access rules".
-   *   - description: One sentence describing the test, starting with a verb.
-   *   - group: The human-readable name of the module ("Node", "Statistics"), or
-   *     the human-readable name of the Drupal facility tested (e.g. "Form API"
-   *     or "XML-RPC").
-   */
-  public static function getInfo() {
-    // PHP does not allow us to declare this method as abstract public static,
-    // so we simply throw an exception here if this has not been implemented by
-    // a child class.
-    throw new \RuntimeException(String::format('@class must implement \Drupal\Tests\UnitTestCase::getInfo().', array(
-      '@class' => get_called_class(),
-    )));
-  }
-
-  /**
    * {@inheritdoc}
    */
   protected function setUp() {
