Newer
Older
<?php
/**
* @file
* Contains \Drupal\simpletest\TestDiscovery.
*/
namespace Drupal\simpletest;
catch
committed
use Doctrine\Common\Annotations\SimpleAnnotationReader;
use Doctrine\Common\Reflection\StaticReflectionParser;
use Drupal\Component\Annotation\Reflection\MockFileFinder;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Extension\ExtensionDiscovery;
use Drupal\Core\Extension\ModuleHandlerInterface;
catch
committed
use Drupal\simpletest\Exception\MissingGroupException;
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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;
/**
* The app root.
*
* @var string
*/
protected $root;
/**
* The module handler.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Constructs a new test discovery.
*
* @param string $root
* The app root.
Alex Pott
committed
* @param $class_loader
* The class loader. Normally Composer's ClassLoader, as included by the
* front controller, but may also be decorated; e.g.,
* \Symfony\Component\ClassLoader\ApcClassLoader.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The module handler.
* @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
* (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;
$this->moduleHandler = $module_handler;
$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();
Alex Pott
committed
// 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 = array();
foreach ($this->getExtensions() as $name => $extension) {
$this->availableExtensions[$extension->getType()][$name] = $name;
$base_path = $this->root . '/' . $extension->getPath();
// Add namespace of disabled/uninstalled extensions.
Dries Buytaert
committed
if (!isset($existing["Drupal\\$name\\"])) {
$this->classLoader->addPsr4("Drupal\\$name\\", "$base_path/src");
}
// Add Simpletest test namespace.
Dries Buytaert
committed
$this->testNamespaces["Drupal\\$name\\Tests\\"][] = "$base_path/src/Tests";
Alex Pott
committed
// 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";
Alex Pott
committed
$this->testNamespaces["Drupal\\Tests\\$name\\Functional\\"][] = "$base_path/tests/src/Functional";
$this->testNamespaces["Drupal\\Tests\\$name\\FunctionalJavascript\\"][] = "$base_path/tests/src/FunctionalJavascript";
}
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'.
* @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
*/
public function getTestClasses($extension = NULL, array $types = []) {
catch
committed
$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 = 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) {
catch
committed
$finder = MockFileFinder::create($pathname);
$parser = new StaticReflectionParser($classname, $finder, TRUE);
try {
catch
committed
$info = static::getTestInfo($classname, $parser->getDocComment());
}
catch
committed
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);
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 = array();
$namespaces = $this->registerTestNamespaces();
if (isset($extension)) {
Angie Byron
committed
// 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))));
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
}
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.
*
catch
committed
* @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.
*
catch
committed
* @throws \Drupal\simpletest\Exception\MissingGroupException
* If the class does not have a @group annotation.
*/
catch
committed
public static function getTestInfo($classname, $doc_comment = NULL) {
if (!$doc_comment) {
$reflection = new \ReflectionClass($classname);
$doc_comment = $reflection->getDocComment();
}
$info = array(
'name' => $classname,
);
catch
committed
$annotations = array();
Alex Pott
committed
// Look for annotations, allow an arbitrary amount of spaces before the
// * but nothing else.
preg_match_all('/^[ ]*\* \@([^\s]*) (.*$)/m', $doc_comment, $matches);
catch
committed
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];
}
}
catch
committed
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';
}
catch
committed
if (!empty($annotations['coversDefaultClass'])) {
$info['description'] = 'Tests ' . $annotations['coversDefaultClass'] . '.';
}
else {
catch
committed
$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.
*
catch
committed
* @param string $doc_comment.
*
* @return string
catch
committed
* The parsed phpDoc summary line. An empty string is returned if no summary
* line can be parsed.
*/
catch
committed
public static function parseTestClassSummary($doc_comment) {
// Normalize line endings.
catch
committed
$doc_comment = preg_replace('/\r\n|\r/', '\n', $doc_comment);
// Strip leading and trailing doc block lines.
catch
committed
$doc_comment = substr($doc_comment, 4, -4);
catch
committed
$lines = explode("\n", $doc_comment);
$summary = [];
Alex Pott
committed
// Add every line to the summary until the first empty line or annotation
// is found.
catch
committed
foreach ($lines as $line) {
Alex Pott
committed
if (preg_match('/^[ ]*\*$/', $line) || preg_match('/^[ ]*\* \@/', $line)) {
catch
committed
break;
}
$summary[] = trim($line, ' *');
}
catch
committed
return implode(' ', $summary);
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
}
/**
* 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;
}
Alex Pott
committed
/**
* Determines the phpunit testsuite for a given classname.
Alex Pott
committed
*
* @param string $classname
Alex Pott
committed
* The test classname.
*
* @return string|false
* The testsuite name or FALSE if its not a phpunit test.
Alex Pott
committed
*/
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';
Alex Pott
committed
}
return $matches[1];
Alex Pott
committed
}
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(array());
$extensions = $listing->scan('module', TRUE);
$extensions += $listing->scan('profile', TRUE);
$extensions += $listing->scan('theme', TRUE);
return $extensions;
}
}