You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
683 lines
18 KiB
683 lines
18 KiB
<?php declare(strict_types=1);
|
|
/*
|
|
* This file is part of PHPUnit.
|
|
*
|
|
* (c) Sebastian Bergmann <sebastian@phpunit.de>
|
|
*
|
|
* 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 assert;
|
|
use function call_user_func;
|
|
use function class_exists;
|
|
use function count;
|
|
use function implode;
|
|
use function is_callable;
|
|
use function is_file;
|
|
use function is_subclass_of;
|
|
use function sprintf;
|
|
use function str_ends_with;
|
|
use function str_starts_with;
|
|
use function trim;
|
|
use Iterator;
|
|
use IteratorAggregate;
|
|
use PHPUnit\Event;
|
|
use PHPUnit\Event\Code\TestMethod;
|
|
use PHPUnit\Event\NoPreviousThrowableException;
|
|
use PHPUnit\Metadata\Api\Dependencies;
|
|
use PHPUnit\Metadata\Api\Groups;
|
|
use PHPUnit\Metadata\Api\HookMethods;
|
|
use PHPUnit\Metadata\Api\Requirements;
|
|
use PHPUnit\Metadata\MetadataCollection;
|
|
use PHPUnit\Runner\Exception as RunnerException;
|
|
use PHPUnit\Runner\Filter\Factory;
|
|
use PHPUnit\Runner\PhptTestCase;
|
|
use PHPUnit\Runner\TestSuiteLoader;
|
|
use PHPUnit\TestRunner\TestResult\Facade as TestResultFacade;
|
|
use PHPUnit\Util\Filter;
|
|
use PHPUnit\Util\Reflection;
|
|
use PHPUnit\Util\Test as TestUtil;
|
|
use ReflectionClass;
|
|
use ReflectionMethod;
|
|
use SebastianBergmann\CodeCoverage\UnintentionallyCoveredCodeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* @template-implements IteratorAggregate<int, Test>
|
|
*
|
|
* @internal This class is not covered by the backward compatibility promise for PHPUnit
|
|
*/
|
|
class TestSuite implements IteratorAggregate, Reorderable, SelfDescribing, Test
|
|
{
|
|
/**
|
|
* @psalm-var non-empty-string
|
|
*/
|
|
private string $name;
|
|
|
|
/**
|
|
* @psalm-var array<string,list<Test>>
|
|
*/
|
|
private array $groups = [];
|
|
private ?array $requiredTests = null;
|
|
|
|
/**
|
|
* @psalm-var list<Test>
|
|
*/
|
|
private array $tests = [];
|
|
private ?array $providedTests = null;
|
|
private ?Factory $iteratorFilter = null;
|
|
|
|
/**
|
|
* @psalm-param non-empty-string $name
|
|
*/
|
|
public static function empty(string $name): static
|
|
{
|
|
return new static($name);
|
|
}
|
|
|
|
/**
|
|
* @psalm-param class-string $className
|
|
*/
|
|
public static function fromClassName(string $className): static
|
|
{
|
|
assert(class_exists($className));
|
|
|
|
$class = new ReflectionClass($className);
|
|
|
|
return static::fromClassReflector($class);
|
|
}
|
|
|
|
public static function fromClassReflector(ReflectionClass $class): static
|
|
{
|
|
$testSuite = new static($class->getName());
|
|
|
|
$constructor = $class->getConstructor();
|
|
|
|
if ($constructor !== null && !$constructor->isPublic()) {
|
|
Event\Facade::emitter()->testRunnerTriggeredWarning(
|
|
sprintf(
|
|
'Class "%s" has no public constructor.',
|
|
$class->getName(),
|
|
),
|
|
);
|
|
|
|
return $testSuite;
|
|
}
|
|
|
|
foreach (Reflection::publicMethodsInTestClass($class) as $method) {
|
|
if ($method->getDeclaringClass()->getName() === Assert::class) {
|
|
continue;
|
|
}
|
|
|
|
if ($method->getDeclaringClass()->getName() === TestCase::class) {
|
|
continue;
|
|
}
|
|
|
|
if (!TestUtil::isTestMethod($method)) {
|
|
continue;
|
|
}
|
|
|
|
$testSuite->addTestMethod($class, $method);
|
|
}
|
|
|
|
if (count($testSuite) === 0) {
|
|
Event\Facade::emitter()->testRunnerTriggeredWarning(
|
|
sprintf(
|
|
'No tests found in class "%s".',
|
|
$class->getName(),
|
|
),
|
|
);
|
|
}
|
|
|
|
return $testSuite;
|
|
}
|
|
|
|
/**
|
|
* @psalm-param non-empty-string $name
|
|
*/
|
|
final private function __construct(string $name)
|
|
{
|
|
$this->name = $name;
|
|
}
|
|
|
|
/**
|
|
* Returns a string representation of the test suite.
|
|
*/
|
|
public function toString(): string
|
|
{
|
|
return $this->name();
|
|
}
|
|
|
|
/**
|
|
* Adds a test to the suite.
|
|
*/
|
|
public function addTest(Test $test, array $groups = []): void
|
|
{
|
|
$class = new ReflectionClass($test);
|
|
|
|
if (!$class->isAbstract()) {
|
|
$this->tests[] = $test;
|
|
$this->clearCaches();
|
|
|
|
if ($test instanceof self && empty($groups)) {
|
|
$groups = $test->groups();
|
|
}
|
|
|
|
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.
|
|
*
|
|
* @throws Exception
|
|
*/
|
|
public function addTestSuite(ReflectionClass $testClass): void
|
|
{
|
|
if ($testClass->isAbstract()) {
|
|
throw new Exception(
|
|
sprintf(
|
|
'Class %s is abstract',
|
|
$testClass->getName(),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!$testClass->isSubclassOf(TestCase::class)) {
|
|
throw new Exception(
|
|
sprintf(
|
|
'Class %s is not a subclass of %s',
|
|
$testClass->getName(),
|
|
TestCase::class,
|
|
),
|
|
);
|
|
}
|
|
|
|
$this->addTest(self::fromClassReflector($testClass));
|
|
}
|
|
|
|
/**
|
|
* 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) && str_ends_with($filename, '.phpt')) {
|
|
try {
|
|
$this->addTest(new PhptTestCase($filename));
|
|
} catch (RunnerException $e) {
|
|
Event\Facade::emitter()->testRunnerTriggeredWarning(
|
|
$e->getMessage(),
|
|
);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
$this->addTestSuite(
|
|
(new TestSuiteLoader)->load($filename),
|
|
);
|
|
} catch (RunnerException $e) {
|
|
Event\Facade::emitter()->testRunnerTriggeredWarning(
|
|
$e->getMessage(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
public function count(): int
|
|
{
|
|
$numTests = 0;
|
|
|
|
foreach ($this as $test) {
|
|
$numTests += count($test);
|
|
}
|
|
|
|
return $numTests;
|
|
}
|
|
|
|
public function isEmpty(): bool
|
|
{
|
|
return empty($this->tests);
|
|
}
|
|
|
|
/**
|
|
* @psalm-return non-empty-string
|
|
*/
|
|
public function name(): string
|
|
{
|
|
return $this->name;
|
|
}
|
|
|
|
/**
|
|
* Returns the test groups of the suite.
|
|
*
|
|
* @psalm-return list<string>
|
|
*/
|
|
public function groups(): array
|
|
{
|
|
return array_map(
|
|
'strval',
|
|
array_keys($this->groups),
|
|
);
|
|
}
|
|
|
|
public function groupDetails(): array
|
|
{
|
|
return $this->groups;
|
|
}
|
|
|
|
/**
|
|
* @throws \SebastianBergmann\CodeCoverage\InvalidArgumentException
|
|
* @throws CodeCoverageException
|
|
* @throws Event\RuntimeException
|
|
* @throws Exception
|
|
* @throws NoPreviousThrowableException
|
|
* @throws UnintentionallyCoveredCodeException
|
|
*/
|
|
public function run(): void
|
|
{
|
|
if (count($this) === 0) {
|
|
return;
|
|
}
|
|
|
|
$emitter = Event\Facade::emitter();
|
|
$testSuiteValueObjectForEvents = Event\TestSuite\TestSuiteBuilder::from($this);
|
|
|
|
$emitter->testSuiteStarted($testSuiteValueObjectForEvents);
|
|
|
|
if (!$this->invokeMethodsBeforeFirstTest($emitter, $testSuiteValueObjectForEvents)) {
|
|
return;
|
|
}
|
|
|
|
foreach ($this as $test) {
|
|
if (TestResultFacade::shouldStop()) {
|
|
$emitter->testRunnerExecutionAborted();
|
|
|
|
break;
|
|
}
|
|
|
|
$test->run();
|
|
}
|
|
|
|
$this->invokeMethodsAfterLastTest($emitter);
|
|
|
|
$emitter->testSuiteFinished($testSuiteValueObjectForEvents);
|
|
}
|
|
|
|
/**
|
|
* Returns the tests as an enumeration.
|
|
*
|
|
* @psalm-return list<Test>
|
|
*/
|
|
public function tests(): array
|
|
{
|
|
return $this->tests;
|
|
}
|
|
|
|
/**
|
|
* Set tests of the test suite.
|
|
*
|
|
* @psalm-param list<Test> $tests
|
|
*/
|
|
public function setTests(array $tests): void
|
|
{
|
|
$this->tests = $tests;
|
|
}
|
|
|
|
/**
|
|
* Mark the test suite as skipped.
|
|
*
|
|
* @throws SkippedTestSuiteError
|
|
*/
|
|
public function markTestSuiteSkipped(string $message = ''): never
|
|
{
|
|
throw new SkippedTestSuiteError($message);
|
|
}
|
|
|
|
/**
|
|
* 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 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)) {
|
|
continue;
|
|
}
|
|
|
|
$this->providedTests = ExecutionOrderDependency::mergeUnique($this->providedTests, $test->provides());
|
|
}
|
|
}
|
|
|
|
return $this->providedTests;
|
|
}
|
|
|
|
/**
|
|
* @psalm-return list<ExecutionOrderDependency>
|
|
*/
|
|
public function requires(): array
|
|
{
|
|
if ($this->requiredTests === null) {
|
|
$this->requiredTests = [];
|
|
|
|
foreach ($this->tests as $test) {
|
|
if (!($test instanceof Reorderable)) {
|
|
continue;
|
|
}
|
|
|
|
$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->name() . '::class';
|
|
}
|
|
|
|
/**
|
|
* @psalm-assert-if-true class-string $this->name
|
|
*/
|
|
public function isForTestClass(): bool
|
|
{
|
|
return class_exists($this->name, false) && is_subclass_of($this->name, TestCase::class);
|
|
}
|
|
|
|
/**
|
|
* @throws \PHPUnit\Event\TestData\MoreThanOneDataSetFromDataProviderException
|
|
* @throws Exception
|
|
*/
|
|
protected function addTestMethod(ReflectionClass $class, ReflectionMethod $method): void
|
|
{
|
|
$className = $class->getName();
|
|
$methodName = $method->getName();
|
|
|
|
assert(!empty($methodName));
|
|
|
|
try {
|
|
$test = (new TestBuilder)->build($class, $methodName);
|
|
} catch (InvalidDataProviderException $e) {
|
|
Event\Facade::emitter()->testTriggeredPhpunitError(
|
|
new TestMethod(
|
|
$className,
|
|
$methodName,
|
|
$class->getFileName(),
|
|
$method->getStartLine(),
|
|
Event\Code\TestDoxBuilder::fromClassNameAndMethodName(
|
|
$className,
|
|
$methodName,
|
|
),
|
|
MetadataCollection::fromArray([]),
|
|
Event\TestData\TestDataCollection::fromArray([]),
|
|
),
|
|
sprintf(
|
|
"The data provider specified for %s::%s is invalid\n%s",
|
|
$className,
|
|
$methodName,
|
|
$this->throwableToString($e),
|
|
),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
if ($test instanceof TestCase || $test instanceof DataProviderTestSuite) {
|
|
$test->setDependencies(
|
|
Dependencies::dependencies($class->getName(), $methodName),
|
|
);
|
|
}
|
|
|
|
$this->addTest(
|
|
$test,
|
|
(new Groups)->groups($class->getName(), $methodName),
|
|
);
|
|
}
|
|
|
|
private function clearCaches(): void
|
|
{
|
|
$this->providedTests = null;
|
|
$this->requiredTests = null;
|
|
}
|
|
|
|
private function containsOnlyVirtualGroups(array $groups): bool
|
|
{
|
|
foreach ($groups as $group) {
|
|
if (!str_starts_with($group, '__phpunit_')) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function methodDoesNotExistOrIsDeclaredInTestCase(string $methodName): bool
|
|
{
|
|
$reflector = new ReflectionClass($this->name);
|
|
|
|
return !$reflector->hasMethod($methodName) ||
|
|
$reflector->getMethod($methodName)->getDeclaringClass()->getName() === TestCase::class;
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
*/
|
|
private function throwableToString(Throwable $t): string
|
|
{
|
|
$message = $t->getMessage();
|
|
|
|
if (empty(trim($message))) {
|
|
$message = '<no message>';
|
|
}
|
|
|
|
if ($t instanceof InvalidDataProviderException) {
|
|
return sprintf(
|
|
"%s\n%s",
|
|
$message,
|
|
Filter::getFilteredStacktrace($t),
|
|
);
|
|
}
|
|
|
|
return sprintf(
|
|
"%s: %s\n%s",
|
|
$t::class,
|
|
$message,
|
|
Filter::getFilteredStacktrace($t),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @throws Exception
|
|
* @throws NoPreviousThrowableException
|
|
*/
|
|
private function invokeMethodsBeforeFirstTest(Event\Emitter $emitter, Event\TestSuite\TestSuite $testSuiteValueObjectForEvents): bool
|
|
{
|
|
if (!$this->isForTestClass()) {
|
|
return true;
|
|
}
|
|
|
|
$methodsCalledBeforeFirstTest = [];
|
|
|
|
$beforeClassMethods = (new HookMethods)->hookMethods($this->name)['beforeClass'];
|
|
|
|
try {
|
|
foreach ($beforeClassMethods as $beforeClassMethod) {
|
|
if ($this->methodDoesNotExistOrIsDeclaredInTestCase($beforeClassMethod)) {
|
|
continue;
|
|
}
|
|
|
|
if ($missingRequirements = (new Requirements)->requirementsNotSatisfiedFor($this->name, $beforeClassMethod)) {
|
|
$this->markTestSuiteSkipped(implode(PHP_EOL, $missingRequirements));
|
|
}
|
|
|
|
$methodCalledBeforeFirstTest = new Event\Code\ClassMethod(
|
|
$this->name,
|
|
$beforeClassMethod,
|
|
);
|
|
|
|
$emitter->testBeforeFirstTestMethodCalled(
|
|
$this->name,
|
|
$methodCalledBeforeFirstTest,
|
|
);
|
|
|
|
$methodsCalledBeforeFirstTest[] = $methodCalledBeforeFirstTest;
|
|
|
|
call_user_func([$this->name, $beforeClassMethod]);
|
|
}
|
|
} catch (SkippedTest|SkippedTestSuiteError $e) {
|
|
$emitter->testSuiteSkipped(
|
|
$testSuiteValueObjectForEvents,
|
|
$e->getMessage(),
|
|
);
|
|
|
|
return false;
|
|
} catch (Throwable $t) {
|
|
assert(isset($methodCalledBeforeFirstTest));
|
|
|
|
$emitter->testBeforeFirstTestMethodErrored(
|
|
$this->name,
|
|
$methodCalledBeforeFirstTest,
|
|
Event\Code\ThrowableBuilder::from($t),
|
|
);
|
|
|
|
if (!empty($methodsCalledBeforeFirstTest)) {
|
|
$emitter->testBeforeFirstTestMethodFinished(
|
|
$this->name,
|
|
...$methodsCalledBeforeFirstTest,
|
|
);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
if (!empty($methodsCalledBeforeFirstTest)) {
|
|
$emitter->testBeforeFirstTestMethodFinished(
|
|
$this->name,
|
|
...$methodsCalledBeforeFirstTest,
|
|
);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private function invokeMethodsAfterLastTest(Event\Emitter $emitter): void
|
|
{
|
|
if (!$this->isForTestClass()) {
|
|
return;
|
|
}
|
|
|
|
$methodsCalledAfterLastTest = [];
|
|
|
|
$afterClassMethods = (new HookMethods)->hookMethods($this->name)['afterClass'];
|
|
|
|
foreach ($afterClassMethods as $afterClassMethod) {
|
|
if ($this->methodDoesNotExistOrIsDeclaredInTestCase($afterClassMethod)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
call_user_func([$this->name, $afterClassMethod]);
|
|
|
|
$methodCalledAfterLastTest = new Event\Code\ClassMethod(
|
|
$this->name,
|
|
$afterClassMethod,
|
|
);
|
|
|
|
$emitter->testAfterLastTestMethodCalled(
|
|
$this->name,
|
|
$methodCalledAfterLastTest,
|
|
);
|
|
|
|
$methodsCalledAfterLastTest[] = $methodCalledAfterLastTest;
|
|
} catch (Throwable) {
|
|
// @todo
|
|
}
|
|
}
|
|
|
|
if (!empty($methodsCalledAfterLastTest)) {
|
|
$emitter->testAfterLastTestMethodFinished(
|
|
$this->name,
|
|
...$methodsCalledAfterLastTest,
|
|
);
|
|
}
|
|
}
|
|
}
|