diff --git a/core/lib/Drupal/Core/Test/Exception/MissingGroupException.php b/core/lib/Drupal/Core/Test/Exception/MissingGroupException.php new file mode 100644 index 0000000000..448b4f5b2d --- /dev/null +++ b/core/lib/Drupal/Core/Test/Exception/MissingGroupException.php @@ -0,0 +1,11 @@ +root = $root; $this->classLoader = $class_loader; - $this->moduleHandler = $module_handler; - $this->cacheBackend = $cache_backend; } /** @@ -156,14 +133,6 @@ public function registerTestNamespaces() { * @see https://www.drupal.org/node/2296615 */ public function getTestClasses($extension = NULL, array $types = []) { - $reader = new SimpleAnnotationReader(); - $reader->addNamespace('Drupal\\simpletest\\Annotation'); - - if (!isset($extension)) { - if ($this->cacheBackend && $cache = $this->cacheBackend->get('simpletest:discovery:classes')) { - return $cache->data; - } - } $list = []; $classmap = $this->findAllClassFiles($extension); @@ -209,14 +178,8 @@ public function getTestClasses($extension = NULL, array $types = []) { uksort($tests, 'strnatcasecmp'); } - // Allow modules extending core tests to disable originals. - $this->moduleHandler->alter('simpletest', $list); - - if (!isset($extension)) { - if ($this->cacheBackend) { - $this->cacheBackend->set('simpletest:discovery:classes', $list); - } - } + // Allow subclasses to perform module hooks on the test list. + $this->alterHook($list); if ($types) { $list = NestedArray::filter($list, function ($element) use ($types) { @@ -228,6 +191,18 @@ public function getTestClasses($extension = NULL, array $types = []) { } /** + * Allow subclasses to invoke module hooks on the test list. + * + * This method can manipulate the test list. + * + * @param array $test_list + * An array of tests keyed by the the group name. + */ + protected function alterHook(&$test_list) { + return; + } + + /** * Discovers all class files in all available extensions. * * @param string $extension @@ -309,7 +284,7 @@ public static function scanDirectory($namespace_prefix, $path) { /** * Retrieves information about a test class for UI purposes. * - * @param string $class + * @param string $classname * The test classname. * @param string $doc_comment * (optional) The class PHPDoc comment. If not passed in reflection will be @@ -487,3 +462,4 @@ protected function getExtensions() { } } + diff --git a/core/modules/simpletest/simpletest.services.yml b/core/modules/simpletest/simpletest.services.yml index 8b645deb24..b6bff1315b 100644 --- a/core/modules/simpletest/simpletest.services.yml +++ b/core/modules/simpletest/simpletest.services.yml @@ -1,4 +1,6 @@ services: test_discovery: + # @todo Change this service so that it uses \Drupal\Core\Test\TestDiscovery + # in https://www.drupal.org/node/1667822 class: Drupal\simpletest\TestDiscovery arguments: ['@app.root', '@class_loader', '@module_handler', '@?cache.discovery'] diff --git a/core/modules/simpletest/src/Exception/MissingGroupException.php b/core/modules/simpletest/src/Exception/MissingGroupException.php index 85a0696ca1..80f8944d69 100644 --- a/core/modules/simpletest/src/Exception/MissingGroupException.php +++ b/core/modules/simpletest/src/Exception/MissingGroupException.php @@ -2,8 +2,17 @@ namespace Drupal\simpletest\Exception; +@trigger_error('Deprecated in Drupal 8.4.x for removal before Drupal 9.0.0 release. Use \Drupal\Core\Test\Exception\MissingGroupException instead.', E_USER_DEPRECATED); + +use Drupal\Core\Test\Exception\MissingGroupException as CoreMissingGroupException; + /** * Exception thrown when a simpletest class is missing an @group annotation. + * + * @deprecated in Drupal 8.4.x for removal before Drupal 9.0.0 release. Use + * \Drupal\Core\Test\Exception\MissingGroupException instead. + * + * @see \Drupal\Core\Test\Exception\MissingGroupException */ -class MissingGroupException extends \LogicException { +class MissingGroupException extends CoreMissingGroupException { } diff --git a/core/modules/simpletest/src/TestDiscovery.php b/core/modules/simpletest/src/TestDiscovery.php index 5374520e56..a016af9628 100644 --- a/core/modules/simpletest/src/TestDiscovery.php +++ b/core/modules/simpletest/src/TestDiscovery.php @@ -2,27 +2,22 @@ namespace Drupal\simpletest; -use Doctrine\Common\Annotations\SimpleAnnotationReader; -use Doctrine\Common\Reflection\StaticReflectionParser; -use Drupal\Component\Annotation\Reflection\MockFileFinder; -use Drupal\Component\Utility\NestedArray; +@trigger_error('Deprecated in Drupal 8.4.x for removal before Drupal 9.0.0 release. Use \Drupal\Core\Test\TestDiscovery instead.', E_USER_DEPRECATED); + use Drupal\Core\Cache\CacheBackendInterface; -use Drupal\Core\Extension\ExtensionDiscovery; use Drupal\Core\Extension\ModuleHandlerInterface; -use Drupal\simpletest\Exception\MissingGroupException; -use PHPUnit_Util_Test; +use Drupal\Core\Test\TestDiscovery as CoreTestDiscovery; /** * Discovers available tests. + * + * This class provides backwards compatibility for code which uses the legacy + * \Drupal\simpletest\TestDiscovery. + * + * @deprecated in Drupal 8.4.x, will be removed before Drupal 9.0.0. Use + * Drupal\Core\Test\TestDiscovery instead. */ -class TestDiscovery { - - /** - * The class loader. - * - * @var \Composer\Autoload\ClassLoader - */ - protected $classLoader; +class TestDiscovery extends CoreTestDiscovery { /** * Backend for caching discovery results. @@ -32,27 +27,6 @@ class TestDiscovery { 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; - - /** - * The app root. - * - * @var string - */ - protected $root; - - /** * The module handler. * * @var \Drupal\Core\Extension\ModuleHandlerInterface @@ -74,416 +48,38 @@ class TestDiscovery { * (optional) Backend for caching discovery results. */ public function __construct($root, $class_loader, ModuleHandlerInterface $module_handler, CacheBackendInterface $cache_backend = NULL) { - $this->root = $root; - $this->classLoader = $class_loader; + parent::__construct($root, $class_loader); $this->moduleHandler = $module_handler; $this->cacheBackend = $cache_backend; } /** - * Registers test namespaces of all extensions and core test classes. - * - * @return array - * An associative array whose keys are PSR-4 namespace prefixes and whose - * values are directory names. + * {@inheritdoc} */ - public function registerTestNamespaces() { - if (isset($this->testNamespaces)) { - return $this->testNamespaces; - } - $this->testNamespaces = []; - - $existing = $this->classLoader->getPrefixesPsr4(); - - // Add PHPUnit test namespaces of Drupal core. - $this->testNamespaces['Drupal\\Tests\\'] = [$this->root . '/core/tests/Drupal/Tests']; - $this->testNamespaces['Drupal\\KernelTests\\'] = [$this->root . '/core/tests/Drupal/KernelTests']; - $this->testNamespaces['Drupal\\FunctionalTests\\'] = [$this->root . '/core/tests/Drupal/FunctionalTests']; - $this->testNamespaces['Drupal\\FunctionalJavascriptTests\\'] = [$this->root . '/core/tests/Drupal/FunctionalJavascriptTests']; - - $this->availableExtensions = []; - foreach ($this->getExtensions() as $name => $extension) { - $this->availableExtensions[$extension->getType()][$name] = $name; - - $base_path = $this->root . '/' . $extension->getPath(); - - // Add namespace of disabled/uninstalled extensions. - if (!isset($existing["Drupal\\$name\\"])) { - $this->classLoader->addPsr4("Drupal\\$name\\", "$base_path/src"); - } - // Add Simpletest test namespace. - $this->testNamespaces["Drupal\\$name\\Tests\\"][] = "$base_path/src/Tests"; - - // Add PHPUnit test namespaces. - $this->testNamespaces["Drupal\\Tests\\$name\\Unit\\"][] = "$base_path/tests/src/Unit"; - $this->testNamespaces["Drupal\\Tests\\$name\\Kernel\\"][] = "$base_path/tests/src/Kernel"; - $this->testNamespaces["Drupal\\Tests\\$name\\Functional\\"][] = "$base_path/tests/src/Functional"; - $this->testNamespaces["Drupal\\Tests\\$name\\FunctionalJavascript\\"][] = "$base_path/tests/src/FunctionalJavascript"; - - // Add discovery for traits which are shared between different test - // suites. - $this->testNamespaces["Drupal\\Tests\\$name\\Traits\\"][] = "$base_path/tests/src/Traits"; - } - - foreach ($this->testNamespaces as $prefix => $paths) { - $this->classLoader->addPsr4($prefix, $paths); - } - - return $this->testNamespaces; + protected function alterHook(&$test_list) { + // Allow modules extending core tests to disable originals. + $this->moduleHandler->alter('simpletest', $test_list); } /** - * Discovers all available tests in all extensions. - * - * @param string $extension - * (optional) The name of an extension to limit discovery to; e.g., 'node'. - * @param string[] $types - * An array of included test types. - * - * @return array - * An array of tests keyed by the the group name. - * @code - * $groups['block'] => array( - * 'Drupal\block\Tests\BlockTest' => array( - * 'name' => 'Drupal\block\Tests\BlockTest', - * 'description' => 'Tests block UI CRUD functionality.', - * 'group' => 'block', - * ), - * ); - * @endcode - * - * @todo Remove singular grouping; retain list of groups in 'group' key. - * @see https://www.drupal.org/node/2296615 + * {@inheritdoc} */ public function getTestClasses($extension = NULL, array $types = []) { - $reader = new SimpleAnnotationReader(); - $reader->addNamespace('Drupal\\simpletest\\Annotation'); - + // The simpletest test discovery service can cache results. if (!isset($extension)) { if ($this->cacheBackend && $cache = $this->cacheBackend->get('simpletest:discovery:classes')) { return $cache->data; } } - $list = []; - - $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) { - $finder = MockFileFinder::create($pathname); - $parser = new StaticReflectionParser($classname, $finder, TRUE); - try { - $info = static::getTestInfo($classname, $parser->getDocComment()); - } - catch (MissingGroupException $e) { - // If the class name ends in Test and is not a migrate table dump. - if (preg_match('/Test$/', $classname) && strpos($classname, 'migrate_drupal\Tests\Table') === FALSE) { - throw $e; - } - // If the class is @group annotation just skip it. Most likely it is an - // abstract class, trait or test fixture. - continue; - } - // 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. - $this->moduleHandler->alter('simpletest', $list); + $list = parent::getTestClasses($extension, $types); if (!isset($extension)) { if ($this->cacheBackend) { $this->cacheBackend->set('simpletest:discovery:classes', $list); } } - - if ($types) { - $list = NestedArray::filter($list, function ($element) use ($types) { - return !(is_array($element) && isset($element['type']) && !in_array($element['type'], $types)); - }); - } - 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 = []; - $namespaces = $this->registerTestNamespaces(); - if (isset($extension)) { - // Include tests in the \Drupal\Tests\{$extension} namespace. - $pattern = "/Drupal\\\(Tests\\\)?$extension\\\/"; - $namespaces = array_intersect_key($namespaces, array_flip(preg_grep($pattern, 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 = []; - 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 string $class - * The test classname. - * @param string $doc_comment - * (optional) The class PHPDoc comment. If not passed in reflection will be - * used but this is very expensive when parsing all the test classes. - * - * @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 \Drupal\simpletest\Exception\MissingGroupException - * If the class does not have a @group annotation. - */ - public static function getTestInfo($classname, $doc_comment = NULL) { - if (!$doc_comment) { - $reflection = new \ReflectionClass($classname); - $doc_comment = $reflection->getDocComment(); - } - $info = [ - 'name' => $classname, - ]; - $annotations = []; - // Look for annotations, allow an arbitrary amount of spaces before the - // * but nothing else. - preg_match_all('/^[ ]*\* \@([^\s]*) (.*$)/m', $doc_comment, $matches); - if (isset($matches[1])) { - foreach ($matches[1] as $key => $annotation) { - if (!empty($annotations[$annotation])) { - // Only have the first match per annotation. This deals with - // multiple @group annotations. - continue; - } - $annotations[$annotation] = $matches[2][$key]; - } - } - - if (empty($annotations['group'])) { - // Concrete tests must have a group. - throw new MissingGroupException(sprintf('Missing @group annotation in %s', $classname)); - } - $info['group'] = $annotations['group']; - // Put PHPUnit test suites into their own custom groups. - if ($testsuite = static::getPhpunitTestSuite($classname)) { - $info['type'] = 'PHPUnit-' . $testsuite; - } - else { - $info['type'] = 'Simpletest'; - } - - if (!empty($annotations['coversDefaultClass'])) { - $info['description'] = 'Tests ' . $annotations['coversDefaultClass'] . '.'; - } - else { - $info['description'] = static::parseTestClassSummary($doc_comment); - } - if (isset($annotations['dependencies'])) { - $info['requires']['module'] = array_map('trim', explode(',', $annotations['dependencies'])); - } - - return $info; - } - - /** - * Parses the phpDoc summary line of a test class. - * - * @param string $doc_comment - * - * @return string - * The parsed phpDoc summary line. An empty string is returned if no summary - * line can be parsed. - */ - public static function parseTestClassSummary($doc_comment) { - // Normalize line endings. - $doc_comment = preg_replace('/\r\n|\r/', '\n', $doc_comment); - // Strip leading and trailing doc block lines. - $doc_comment = substr($doc_comment, 4, -4); - - $lines = explode("\n", $doc_comment); - $summary = []; - // Add every line to the summary until the first empty line or annotation - // is found. - foreach ($lines as $line) { - if (preg_match('/^[ ]*\*$/', $line) || preg_match('/^[ ]*\* \@/', $line)) { - break; - } - $summary[] = trim($line, ' *'); - } - return implode(' ', $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; - } - - /** - * Determines the phpunit testsuite for a given classname. - * - * @param string $classname - * The test classname. - * - * @return string|false - * The testsuite name or FALSE if its not a phpunit test. - */ - public static function getPhpunitTestSuite($classname) { - if (preg_match('/Drupal\\\\Tests\\\\Core\\\\(\w+)/', $classname, $matches)) { - return 'Unit'; - } - if (preg_match('/Drupal\\\\Tests\\\\Component\\\\(\w+)/', $classname, $matches)) { - return 'Unit'; - } - // Module tests. - if (preg_match('/Drupal\\\\Tests\\\\(\w+)\\\\(\w+)/', $classname, $matches)) { - return $matches[2]; - } - // Core tests. - elseif (preg_match('/Drupal\\\\(\w*)Tests\\\\/', $classname, $matches)) { - if ($matches[1] == '') { - return 'Unit'; - } - return $matches[1]; - } - return FALSE; - } - - /** - * Returns all available extensions. - * - * @return \Drupal\Core\Extension\Extension[] - * An array of Extension objects, keyed by extension name. - */ - protected function getExtensions() { - $listing = new ExtensionDiscovery($this->root); - // Ensure that tests in all profiles are discovered. - $listing->setProfileDirectories([]); - $extensions = $listing->scan('module', TRUE); - $extensions += $listing->scan('profile', TRUE); - $extensions += $listing->scan('theme', TRUE); - return $extensions; - } - } diff --git a/core/modules/simpletest/tests/src/Kernel/TestDiscoveryTest.php b/core/modules/simpletest/tests/src/Kernel/TestDiscoveryTest.php new file mode 100644 index 0000000000..7877094dec --- /dev/null +++ b/core/modules/simpletest/tests/src/Kernel/TestDiscoveryTest.php @@ -0,0 +1,32 @@ +markTestIncomplete(); + $class_loader = $this->prophesize(ClassLoader::class); + $module_handler = $this->prophesize(ModuleHandlerInterface::class); + + $test_discovery = new TestDiscovery(\Drupal::root(), $class_loader->reveal(), $module_handler->reveal()); + } +} diff --git a/core/modules/simpletest/tests/src/Unit/TestInfoParsingTest.php b/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php similarity index 93% rename from core/modules/simpletest/tests/src/Unit/TestInfoParsingTest.php rename to core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php index f36243e3d1..5dd3738698 100644 --- a/core/modules/simpletest/tests/src/Unit/TestInfoParsingTest.php +++ b/core/tests/Drupal/Tests/Core/Test/TestDiscoveryTest.php @@ -1,25 +1,21 @@ 'Drupal\Tests\simpletest\Unit\TestInfoParsingTest', - 'group' => 'simpletest', - 'description' => 'Tests \Drupal\simpletest\TestDiscovery.', + 'name' => 'Drupal\Tests\action\Unit\Menu\ActionLocalTasksTest', + 'group' => 'action', + 'description' => 'Tests action local tasks.', 'type' => 'PHPUnit-Unit', ], // Classname. - 'Drupal\Tests\simpletest\Unit\TestInfoParsingTest', + 'Drupal\Tests\action\Unit\Menu\ActionLocalTasksTest', ]; // A core unit test. @@ -244,7 +240,10 @@ public function testTestInfoParserMissingGroup() { * Bulk delete storages and fields, and clean up afterwards. */ EOT; - $this->setExpectedException(MissingGroupException::class, 'Missing @group annotation in Drupal\KernelTests\field\BulkDeleteTest'); + $this->setExpectedException( + MissingGroupException::class, + 'Missing @group annotation in Drupal\KernelTests\field\BulkDeleteTest' + ); TestDiscovery::getTestInfo($classname, $doc_comment); } @@ -300,9 +299,8 @@ class FunctionalExampleTest {} public function testGetTestClasses() { $this->setupVfsWithTestClasses(); $class_loader = $this->prophesize(ClassLoader::class); - $module_handler = $this->prophesize(ModuleHandlerInterface::class); - $test_discovery = new TestTestDiscovery('vfs://drupal', $class_loader->reveal(), $module_handler->reveal()); + $test_discovery = new TestTestDiscovery('vfs://drupal', $class_loader->reveal()); $extensions = [ 'test_module' => new Extension('vfs://drupal', 'module', 'modules/test_module/test_module.info.yml'), @@ -366,26 +364,6 @@ public function testGetTestClassesWithSelectedTypes() { ], $result); } -} - -class TestTestDiscovery extends TestDiscovery { - - /** - * @var \Drupal\Core\Extension\Extension[] - */ - protected $extensions = []; - - public function setExtensions(array $extensions) { - $this->extensions = $extensions; - } - - /** - * {@inheritdoc} - */ - protected function getExtensions() { - return $this->extensions; - } - /** * @covers ::getPhpunitTestSuite * @dataProvider providerTestGetPhpunitTestSuite @@ -413,6 +391,26 @@ public function providerTestGetPhpunitTestSuite() { } +class TestTestDiscovery extends TestDiscovery { + + /** + * @var \Drupal\Core\Extension\Extension[] + */ + protected $extensions = []; + + public function setExtensions(array $extensions) { + $this->extensions = $extensions; + } + + /** + * {@inheritdoc} + */ + protected function getExtensions() { + return $this->extensions; + } + +} + namespace Drupal\simpletest\Tests; use Drupal\simpletest\WebTestBase;