* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace PHPUnit\Runner; use PHPUnit\Framework\DataProviderTestSuite; use PHPUnit\Framework\IterativeTestSuite; use PHPUnit\Framework\Reorderable; use PHPUnit\Framework\Test; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestSuite; use PHPUnit\Runner\TestRunHistory\NullTestRunHistory; use PHPUnit\Runner\TestRunHistory\TestRunHistory; use PHPUnit\Runner\TestRunHistory\TestRunHistoryId; use function array_diff; use function array_merge; use function array_reverse; use function array_splice; use function assert; use function count; use function in_array; use function max; use function shuffle; use function usort; /** * @internal This class is not covered by the backward compatibility promise for PHPUnit */ final class TestSuiteSorter { public const int ORDER_DEFAULT = 0; public const int ORDER_RANDOMIZED = 1; public const int ORDER_REVERSED = 2; public const int ORDER_DEFECTS_FIRST = 3; public const int ORDER_DURATION_ASCENDING = 4; public const int ORDER_SIZE_ASCENDING = 5; public const int ORDER_DURATION_DESCENDING = 6; public const int ORDER_SIZE_DESCENDING = 7; /** * @var non-empty-array */ private const array SIZE_SORT_WEIGHT = [ 'small' => 1, 'medium' => 2, 'large' => 3, 'unknown' => 4, ]; /** * @var array Associative array of (string => DEFECT_SORT_WEIGHT) elements */ private array $defectSortOrder = []; private readonly TestRunHistory $testRunHistory; public function __construct(?TestRunHistory $testRunHistory = null) { $this->testRunHistory = $testRunHistory ?? new NullTestRunHistory; } /** * @throws Exception */ public function reorderTestsInSuite(Test $suite, int $order, bool $resolveDependencies, int $orderDefects): void { $allowedOrders = [ self::ORDER_DEFAULT, self::ORDER_REVERSED, self::ORDER_RANDOMIZED, self::ORDER_DURATION_ASCENDING, self::ORDER_SIZE_ASCENDING, self::ORDER_DURATION_DESCENDING, self::ORDER_SIZE_DESCENDING, ]; if (! in_array($order, $allowedOrders, true)) { // @codeCoverageIgnoreStart throw new InvalidOrderException; // @codeCoverageIgnoreEnd } $allowedOrderDefects = [ self::ORDER_DEFAULT, self::ORDER_DEFECTS_FIRST, ]; if (! in_array($orderDefects, $allowedOrderDefects, true)) { // @codeCoverageIgnoreStart throw new InvalidOrderException; // @codeCoverageIgnoreEnd } if ($suite instanceof IterativeTestSuite) { return; } if ($suite instanceof TestSuite) { foreach ($suite as $_suite) { $this->reorderTestsInSuite($_suite, $order, $resolveDependencies, $orderDefects); } if ($orderDefects === self::ORDER_DEFECTS_FIRST) { $this->addSuiteToDefectSortOrder($suite); } $this->sort($suite, $order, $resolveDependencies, $orderDefects); } } private function sort(TestSuite $suite, int $order, bool $resolveDependencies, int $orderDefects): void { $tests = $suite->tests(); if ($tests === []) { return; } if ($order === self::ORDER_REVERSED) { $tests = $this->reverse($tests); } elseif ($order === self::ORDER_RANDOMIZED) { $tests = $this->randomize($tests); } elseif ($order === self::ORDER_DURATION_ASCENDING) { $tests = $this->sortByDuration($tests); } elseif ($order === self::ORDER_DURATION_DESCENDING) { $tests = $this->sortByDurationDescending($tests); } elseif ($order === self::ORDER_SIZE_ASCENDING) { $tests = $this->sortBySize($tests); } elseif ($order === self::ORDER_SIZE_DESCENDING) { $tests = $this->sortBySizeDescending($tests); } if ($orderDefects === self::ORDER_DEFECTS_FIRST) { $tests = $this->sortDefectsFirst($tests); } if ($resolveDependencies && ! ($suite instanceof DataProviderTestSuite)) { /** @noinspection PhpParamsInspection */ /** @phpstan-ignore argument.type */ $tests = $this->resolveDependencies($tests); } $suite->setTests($tests); } private function addSuiteToDefectSortOrder(TestSuite $suite): void { $max = 0; foreach ($suite->tests() as $test) { assert($test instanceof Reorderable); $sortId = $test->sortId(); if (! isset($this->defectSortOrder[$sortId])) { $this->defectSortOrder[$sortId] = $this->testRunHistory->status(TestRunHistoryId::fromReorderable($test))->sortWeight(); } $max = max($max, $this->defectSortOrder[$sortId]); } $this->defectSortOrder[$suite->sortId()] = $max; } /** * @param list $tests * @return list */ private function reverse(array $tests): array { return array_reverse($tests); } /** * @param list $tests * @return list */ private function randomize(array $tests): array { shuffle($tests); return $tests; } /** * @param list $tests * @return list */ private function sortDefectsFirst(array $tests): array { usort( $tests, fn (Test $left, Test $right) => $this->cmpDefectPriorityAndTime($left, $right), ); return $tests; } /** * @param list $tests * @return list */ private function sortByDuration(array $tests): array { usort( $tests, fn (Test $left, Test $right) => $this->cmpDuration($left, $right), ); return $tests; } /** * @param list $tests * @return list */ private function sortByDurationDescending(array $tests): array { usort( $tests, fn (Test $left, Test $right) => $this->cmpDuration($right, $left), ); return $tests; } /** * @param list $tests * @return list */ private function sortBySize(array $tests): array { usort( $tests, fn (Test $left, Test $right) => $this->cmpSize($left, $right), ); return $tests; } /** * @param list $tests * @return list */ private function sortBySizeDescending(array $tests): array { usort( $tests, fn (Test $left, Test $right) => $this->cmpSize($right, $left), ); return $tests; } private function cmpDefectPriorityAndTime(Test $a, Test $b): int { assert($a instanceof Reorderable); assert($b instanceof Reorderable); $priorityA = $this->defectSortOrder[$a->sortId()] ?? 0; $priorityB = $this->defectSortOrder[$b->sortId()] ?? 0; return $priorityB <=> $priorityA; } private function cmpDuration(Test $a, Test $b): int { return $this->durationWeight($a) <=> $this->durationWeight($b); } private function durationWeight(Test $test): float { if ($test instanceof TestSuite) { $sum = 0.0; foreach ($test->tests() as $inner) { $sum += $this->durationWeight($inner); } return $sum; } if ($test instanceof Reorderable) { return $this->testRunHistory->time(TestRunHistoryId::fromReorderable($test)); } return 0.0; } private function cmpSize(Test $a, Test $b): int { return $this->sizeWeight($a) <=> $this->sizeWeight($b); } /** * @return positive-int */ private function sizeWeight(Test $test): int { if ($test instanceof TestCase || $test instanceof DataProviderTestSuite) { return self::SIZE_SORT_WEIGHT[$test->size()->asString()]; } if ($test instanceof TestSuite) { $max = 0; foreach ($test->tests() as $inner) { $weight = $this->sizeWeight($inner); if ($weight > $max) { $max = $weight; } } if ($max > 0) { return $max; } } return self::SIZE_SORT_WEIGHT['unknown']; } /** * @param list $tests * @return list */ private function resolveDependencies(array $tests): array { if (! $this->anyTestHasDependencies($tests)) { return $tests; } $newTestOrder = []; $i = 0; $provided = []; while ($tests !== [] && $i < count($tests)) { if (array_diff($tests[$i]->requires(), $provided) === []) { $provided = array_merge($provided, $tests[$i]->provides()); $newTestOrder = array_merge($newTestOrder, array_splice($tests, $i, 1)); $i = 0; } else { $i++; } } return array_merge($newTestOrder, $tests); } /** * @param iterable $tests */ private function anyTestHasDependencies(iterable $tests): bool { foreach ($tests as $test) { if ($test instanceof TestSuite) { if ($this->anyTestHasDependencies($test->tests())) { return true; } continue; } if ($test instanceof TestCase && $test->requires() !== []) { return true; } } return false; } }