<?php declare(strict_types=1);
/*
* This file is part of PHPUnit.
*
* (c) Sebastian Bergmann <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPUnit\Framework;
use const PHP_EOL;
use function array_keys;
use function array_map;
use function array_merge;
use function array_slice;
use function array_unique;
use function basename;
use function call_user_func;
use function class_exists;
use function count;
use function dirname;
use function get_declared_classes;
use function implode;
use function is_bool;
use function is_callable;
use function is_file;
use function is_object;
use function is_string;
use function method_exists;
use function preg_match;
use function preg_quote;
use function sprintf;
use function strpos;
use function substr;
use Iterator;
use IteratorAggregate;
use PHPUnit\Runner\BaseTestRunner;
use PHPUnit\Runner\Filter\Factory;
use PHPUnit\Runner\PhptTestCase;
use PHPUnit\Util\FileLoader;
use PHPUnit\Util\Reflection;
use PHPUnit\Util\Test as TestUtil;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
use Throwable;
/**
* @internal This class is not covered by the backward compatibility promise for PHPUnit
*/
class TestSuite implements IteratorAggregate, Reorderable, SelfDescribing, Test
{
/**
* Enable or disable the backup and restoration of the $GLOBALS array.
*
* @var bool
*/
protected $backupGlobals;
/**
* Enable or disable the backup and restoration of static attributes.
*
* @var bool
*/
protected $backupStaticAttributes;
/**
* @var bool
*/
protected $runTestInSeparateProcess = false;
/**
* The name of the test suite.
*
* @var string
*/
protected $name = '';
/**
* The test groups of the test suite.
*
* @psalm-var array<string,list<Test>>
*/
protected $groups = [];
/**
* The tests in the test suite.
*
* @var Test[]
*/
protected $tests = [];
/**
* The number of tests in the test suite.
*
* @var int
*/
protected $numTests = -1;
/**
* @var bool
*/
protected $testCase = false;
/**
* @var string[]
*/
protected $foundClasses = [];
/**
* @var null|list<ExecutionOrderDependency>
*/
protected $providedTests;
/**
* @var null|list<ExecutionOrderDependency>
*/
protected $requiredTests;
/**
* @var bool
*/
private $beStrictAboutChangesToGlobalState;
/**
* @var Factory
*/
private $iteratorFilter;
/**
* @var int
*/
private $declaredClassesPointer;
/**
* @psalm-var array<int,string>
*/
private $warnings = [];
/**
* Constructs a new TestSuite.
*
* - PHPUnit\Framework\TestSuite() constructs an empty TestSuite.
*
* - PHPUnit\Framework\TestSuite(ReflectionClass) constructs a
* TestSuite from the given class.
*
* - PHPUnit\Framework\TestSuite(ReflectionClass, String)
* constructs a TestSuite from the given class with the given
* name.
*
* - PHPUnit\Framework\TestSuite(String) either constructs a
* TestSuite from the given class (if the passed string is the
* name of an existing class) or constructs an empty TestSuite
* with the given name.
*
* @param ReflectionClass|string $theClass
*
* @throws Exception
*/
public function __construct($theClass = '', string $name = '')
{
if (!is_string($theClass) && !$theClass instanceof ReflectionClass) {
throw InvalidArgumentException::create(
1,
'ReflectionClass object or string'
);
}
$this->declaredClassesPointer = count(get_declared_classes());
if (!$theClass instanceof ReflectionClass) {
if (class_exists($theClass, true)) {
if ($name === '') {
$name = $theClass;
}
try {
$theClass = new ReflectionClass($theClass);
} catch (ReflectionException $e) {
throw new Exception(
$e->getMessage(),
(int) $e->getCode(),
$e
);
}
// @codeCoverageIgnoreEnd
} else {
$this->setName($theClass);
return;
}
}
if (!$theClass->isSubclassOf(TestCase::class)) {
$this->setName((string) $theClass);
return;
}
if ($name !== '') {
$this->setName($name);
} else {
$this->setName($theClass->getName());
}
$constructor = $theClass->getConstructor();
if ($constructor !== null &&
!$constructor->isPublic()) {
$this->addTest(
new WarningTestCase(
sprintf(
'Class "%s" has no public constructor.',
$theClass->getName()
)
)
);
return;
}
foreach ((new Reflection)->publicMethodsInTestClass($theClass) as $method) {
if (!TestUtil::isTestMethod($method)) {
continue;
}
$this->addTestMethod($theClass, $method);
}
if (empty($this->tests)) {
$this->addTest(
new WarningTestCase(
sprintf(
'No tests found in class "%s".',
$theClass->getName()
)
)
);
}
$this->testCase = true;
}
/**
* Returns a string representation of the test suite.
*/
public function toString(): string
{
return $this->getName();
}
/**
* Adds a test to the suite.
*
* @param array $groups
*/
public function addTest(Test $test, $groups = []): void
{
try {
$class = new ReflectionClass($test);
// @codeCoverageIgnoreStart
} catch (ReflectionException $e) {
throw new Exception(
$e->getMessage(),
(int) $e->getCode(),
$e
);
}
// @codeCoverageIgnoreEnd
if (!$class->isAbstract()) {
$this->tests[] = $test;
$this->clearCaches();
if ($test instanceof self && empty($groups)) {
$groups = $test->getGroups();
}
if ($this->containsOnlyVirtualGroups($groups)) {
$groups[] = 'default';
}
foreach ($groups as $group) {
if (!isset($this->groups[$group])) {
$this->groups[$group] = [$test];
} else {
$this->groups[$group][] = $test;
}
}
if ($test instanceof TestCase) {
$test->setGroups($groups);
}
}
}
/**
* Adds the tests from the given class to the suite.
*
* @psalm-param object|class-string $testClass
*
* @throws Exception
*/
public function addTestSuite($testClass): void
{
if (!(is_object($testClass) || (is_string($testClass) && class_exists($testClass)))) {
throw InvalidArgumentException::create(
1,
'class name or object'
);
}
if (!is_object($testClass)) {
try {
$testClass = new ReflectionClass($testClass);
// @codeCoverageIgnoreStart
} catch (ReflectionException $e) {
throw new Exception(
$e->getMessage(),
(int) $e->getCode(),
$e
);
}
// @codeCoverageIgnoreEnd
}
if ($testClass instanceof self) {
$this->addTest($testClass);
} elseif ($testClass instanceof ReflectionClass) {
$suiteMethod = false;
if (!$testClass->isAbstract() && $testClass->hasMethod(BaseTestRunner::SUITE_METHODNAME)) {
try {
$method = $testClass->getMethod(
BaseTestRunner::SUITE_METHODNAME
);
// @codeCoverageIgnoreStart
} catch (ReflectionException $e) {
throw new Exception(
$e->getMessage(),
(int) $e->getCode(),
$e
);
}
// @codeCoverageIgnoreEnd
if ($method->isStatic()) {
$this->addTest(
$method->invoke(null, $testClass->getName())
);
$suiteMethod = true;
}
}
if (!$suiteMethod && !$testClass->isAbstract() && $testClass->isSubclassOf(TestCase::class)) {
$this->addTest(new self($testClass));
}
} else {
throw new Exception;
}
}
public function addWarning(string $warning): void
{
$this->warnings[] = $warning;
}
/**
* Wraps both <code>addTest()</code> and <code>addTestSuite</code>
* as well as the separate import statements for the user's convenience.
*
* If the named file cannot be read or there are no new tests that can be
* added, a <code>PHPUnit\Framework\WarningTestCase</code> will be created instead,
* leaving the current test run untouched.
*
* @throws Exception
*/
public function addTestFile(string $filename): void
{
if (is_file($filename) && substr($filename, -5) === '.phpt') {
$this->addTest(new PhptTestCase($filename));
$this->declaredClassesPointer = count(get_declared_classes());
return;
}
$numTests = count($this->tests);
// The given file may contain further stub classes in addition to the
// test class itself. Figure out the actual test class.
$filename = FileLoader::checkAndLoad($filename);
$newClasses = array_slice(get_declared_classes(), $this->declaredClassesPointer);
// The diff is empty in case a parent class (with test methods) is added
// AFTER a child class that inherited from it. To account for that case,
// accumulate all discovered classes, so the parent class may be found in
// a later invocation.
if (!empty($newClasses)) {
// On the assumption that test classes are defined first in files,
// process discovered classes in approximate LIFO order, so as to
// avoid unnecessary reflection.
$this->foundClasses = array_merge($newClasses, $this->foundClasses);
$this->declaredClassesPointer = count(get_declared_classes());
}
// The test class's name must match the filename, either in full, or as
// a PEAR/PSR-0 prefixed short name ('NameSpace_ShortName'), or as a
// PSR-1 local short name ('NameSpace\ShortName'). The comparison must be
// anchored to prevent false-positive matches (e.g., 'OtherShortName').
$shortName = basename($filename, '.php');
$shortNameRegEx = '/(?:^|_|\\\\)' . preg_quote($shortName, '/') . '$/';
foreach ($this->foundClasses as $i => $className) {
if (preg_match($shortNameRegEx, $className)) {
try {
$class = new ReflectionClass($className);
// @codeCoverageIgnoreStart
} catch (ReflectionException $e) {
throw new Exception(
$e->getMessage(),
(int) $e->getCode(),
$e
);
}
// @codeCoverageIgnoreEnd
if ($class->getFileName() == $filename) {
$newClasses = [$className];
unset($this->foundClasses[$i]);
break;
}
}
}
foreach ($newClasses as $className) {
try {
$class = new ReflectionClass($className);
// @codeCoverageIgnoreStart
} catch (ReflectionException $e) {
throw new Exception(
$e->getMessage(),
(int) $e->getCode(),
$e
);
}
// @codeCoverageIgnoreEnd
if (dirname($class->getFileName()) === __DIR__) {
continue;
}
if (!$class->isAbstract()) {
if ($class->hasMethod(BaseTestRunner::SUITE_METHODNAME)) {
try {
$method = $class->getMethod(
BaseTestRunner::SUITE_METHODNAME
);
// @codeCoverageIgnoreStart
} catch (ReflectionException $e) {
throw new Exception(
$e->getMessage(),
(int) $e->getCode(),
$e
);
}
// @codeCoverageIgnoreEnd
if ($method->isStatic()) {
$this->addTest($method->invoke(null, $className));
}
} elseif ($class->implementsInterface(Test::class)) {
// Do we have modern namespacing ('Foo\Bar\WhizBangTest') or old-school namespacing ('Foo_Bar_WhizBangTest')?
$isPsr0 = (!$class->inNamespace()) && (strpos($class->getName(), '_') !== false);
$expectedClassName = $isPsr0 ? $className : $shortName;
if (($pos = strpos($expectedClassName, '.')) !== false) {
$expectedClassName = substr(
$expectedClassName,
0,
$pos
);
}
if ($class->getShortName() !== $expectedClassName) {
$this->addWarning(
sprintf(
"Test case class not matching filename is deprecated\n in %s\n Class name was '%s', expected '%s'",
$filename,
$class->getShortName(),
$expectedClassName
)
);
}
$this->addTestSuite($class);
}
}
}
if (count($this->tests) > ++$numTests) {
$this->addWarning(
sprintf(
"Multiple test case classes per file is deprecated\n in %s",
$filename
)
);
}
$this->numTests = -1;
}
/**
* Wrapper for addTestFile() that adds multiple test files.
*
* @throws Exception
*/
public function addTestFiles(iterable $fileNames): void
{
foreach ($fileNames as $filename) {
$this->addTestFile((string) $filename);
}
}
/**
* Counts the number of test cases that will be run by this test.
*
* @todo refactor usage of numTests in DefaultResultPrinter
*/
public function count(): int
{
$this->numTests = 0;
foreach ($this as $test) {
$this->numTests += count($test);
}
return $this->numTests;
}
/**
* Returns the name of the suite.
*/
public function getName(): string
{
return $this->name;
}
/**
* Returns the test groups of the suite.
*
* @psalm-return list<string>
*/
public function getGroups(): array
{
return array_map(
static function ($key): string
{
return (string) $key;
},
array_keys($this->groups)
);
}
public function getGroupDetails(): array
{
return $this->groups;
}
/**
* Set tests groups of the test case.
*/
public function setGroupDetails(array $groups): void
{
$this->groups = $groups;
}
/**
* Runs the tests and collects their result in a TestResult.
*
* @throws \PHPUnit\Framework\CodeCoverageException
* @throws \SebastianBergmann\CodeCoverage\InvalidArgumentException
* @throws \SebastianBergmann\CodeCoverage\UnintentionallyCoveredCodeException
* @throws \SebastianBergmann\RecursionContext\InvalidArgumentException
* @throws Warning
*/
public function run(TestResult $result = null): TestResult
{
if ($result === null) {
$result = $this->createResult();
}
if (count($this) === 0) {
return $result;
}
/** @psalm-var class-string $className */
$className = $this->name;
$hookMethods = TestUtil::getHookMethods($className);
$result->startTestSuite($this);
$test = null;
if ($this->testCase && class_exists($this->name, false)) {
try {
foreach ($hookMethods['beforeClass'] as $beforeClassMethod) {
if (method_exists($this->name, $beforeClassMethod)) {
if ($missingRequirements = TestUtil::getMissingRequirements($this->name, $beforeClassMethod)) {
$this->markTestSuiteSkipped(implode(PHP_EOL, $missingRequirements));
}
call_user_func([$this->name, $beforeClassMethod]);
}
}
} catch (SkippedTestSuiteError $error) {
foreach ($this->tests() as $test) {
$result->startTest($test);
$result->addFailure($test, $error, 0);
$result->endTest($test, 0);
}
$result->endTestSuite($this);
return $result;
} catch (Throwable $t) {
$errorAdded = false;
foreach ($this->tests() as $test) {
if ($result->shouldStop()) {
break;
}
$result->startTest($test);
if (!$errorAdded) {
$result->addError($test, $t, 0);
$errorAdded = true;
} else {
$result->addFailure(
$test,
new SkippedTestError('Test skipped because of an error in hook method'),
0
);
}
$result->endTest($test, 0);
}
$result->endTestSuite($this);
return $result;
}
}
foreach ($this as $test) {
if ($result->shouldStop()) {
break;
}
if ($test instanceof TestCase || $test instanceof self) {
$test->setBeStrictAboutChangesToGlobalState($this->beStrictAboutChangesToGlobalState);
$test->setBackupGlobals($this->backupGlobals);
$test->setBackupStaticAttributes($this->backupStaticAttributes);
$test->setRunTestInSeparateProcess($this->runTestInSeparateProcess);
}
$test->run($result);
}
if ($this->testCase && class_exists($this->name, false)) {
foreach ($hookMethods['afterClass'] as $afterClassMethod) {
if (method_exists($this->name, $afterClassMethod)) {
try {
call_user_func([$this->name, $afterClassMethod]);
} catch (Throwable $t) {
$message = "Exception in {$this->name}::{$afterClassMethod}" . PHP_EOL . $t->getMessage();
$error = new SyntheticError($message, 0, $t->getFile(), $t->getLine(), $t->getTrace());
$placeholderTest = clone $test;
$placeholderTest->setName($afterClassMethod);
$result->startTest($placeholderTest);
$result->addFailure($placeholderTest, $error, 0);
$result->endTest($placeholderTest, 0);
}
}
}
}
$result->endTestSuite($this);
return $result;
}
public function setRunTestInSeparateProcess(bool $runTestInSeparateProcess): void
{
$this->runTestInSeparateProcess = $runTestInSeparateProcess;
}
public function setName(string $name): void
{
$this->name = $name;
}
/**
* Returns the tests as an enumeration.
*
* @return Test[]
*/
public function tests(): array
{
return $this->tests;
}
/**
* Set tests of the test suite.
*
* @param Test[] $tests
*/
public function setTests(array $tests): void
{
$this->tests = $tests;
}
/**
* Mark the test suite as skipped.
*
* @param string $message
*
* @throws SkippedTestSuiteError
*
* @psalm-return never-return
*/
public function markTestSuiteSkipped($message = ''): void
{
throw new SkippedTestSuiteError($message);
}
/**
* @param bool $beStrictAboutChangesToGlobalState
*/
public function setBeStrictAboutChangesToGlobalState($beStrictAboutChangesToGlobalState): void
{
if (null === $this->beStrictAboutChangesToGlobalState && is_bool($beStrictAboutChangesToGlobalState)) {
$this->beStrictAboutChangesToGlobalState = $beStrictAboutChangesToGlobalState;
}
}
/**
* @param bool $backupGlobals
*/
public function setBackupGlobals($backupGlobals): void
{
if (null === $this->backupGlobals && is_bool($backupGlobals)) {
$this->backupGlobals = $backupGlobals;
}
}
/**
* @param bool $backupStaticAttributes
*/
public function setBackupStaticAttributes($backupStaticAttributes): void
{
if (null === $this->backupStaticAttributes && is_bool($backupStaticAttributes)) {
$this->backupStaticAttributes = $backupStaticAttributes;
}
}
/**
* Returns an iterator for this test suite.
*/
public function getIterator(): Iterator
{
$iterator = new TestSuiteIterator($this);
if ($this->iteratorFilter !== null) {
$iterator = $this->iteratorFilter->factory($iterator, $this);
}
return $iterator;
}
public function injectFilter(Factory $filter): void
{
$this->iteratorFilter = $filter;
foreach ($this as $test) {
if ($test instanceof self) {
$test->injectFilter($filter);
}
}
}
/**
* @psalm-return array<int,string>
*/
public function warnings(): array
{
return array_unique($this->warnings);
}
/**
* @return list<ExecutionOrderDependency>
*/
public function provides(): array
{
if ($this->providedTests === null) {
$this->providedTests = [];
if (is_callable($this->sortId(), true)) {
$this->providedTests[] = new ExecutionOrderDependency($this->sortId());
}
foreach ($this->tests as $test) {
if (!($test instanceof Reorderable)) {
// @codeCoverageIgnoreStart
continue;
// @codeCoverageIgnoreEnd
}
$this->providedTests = ExecutionOrderDependency::mergeUnique($this->providedTests, $test->provides());
}
}
return $this->providedTests;
}
/**
* @return list<ExecutionOrderDependency>
*/
public function requires(): array
{
if ($this->requiredTests === null) {
$this->requiredTests = [];
foreach ($this->tests as $test) {
if (!($test instanceof Reorderable)) {
// @codeCoverageIgnoreStart
continue;
// @codeCoverageIgnoreEnd
}
$this->requiredTests = ExecutionOrderDependency::mergeUnique(
ExecutionOrderDependency::filterInvalid($this->requiredTests),
$test->requires()
);
}
$this->requiredTests = ExecutionOrderDependency::diff($this->requiredTests, $this->provides());
}
return $this->requiredTests;
}
public function sortId(): string
{
return $this->getName() . '::class';
}
/**
* Creates a default TestResult object.
*/
protected function createResult(): TestResult
{
return new TestResult;
}
/**
* @throws Exception
*/
protected function addTestMethod(ReflectionClass $class, ReflectionMethod $method): void
{
$methodName = $method->getName();
$test = (new TestBuilder)->build($class, $methodName);
if ($test instanceof TestCase || $test instanceof DataProviderTestSuite) {
$test->setDependencies(
TestUtil::getDependencies($class->getName(), $methodName)
);
}
$this->addTest(
$test,
TestUtil::getGroups($class->getName(), $methodName)
);
}
private function clearCaches(): void
{
$this->numTests = -1;
$this->providedTests = null;
$this->requiredTests = null;
}
private function containsOnlyVirtualGroups(array $groups): bool
{
foreach ($groups as $group) {
if (strpos($group, '__phpunit_') !== 0) {
return false;
}
}
return true;
}
}