diff --git a/phpunit.xsd b/phpunit.xsd index 231096f622a..dafa061f130 100644 --- a/phpunit.xsd +++ b/phpunit.xsd @@ -132,13 +132,16 @@ + + + diff --git a/schema/13.0.xsd b/schema/13.0.xsd index ad6ac34e3aa..f0ece607d65 100644 --- a/schema/13.0.xsd +++ b/schema/13.0.xsd @@ -123,13 +123,16 @@ + + + diff --git a/src/Runner/TestSuiteSorter.php b/src/Runner/TestSuiteSorter.php index c82a5aedc9f..ebad5891132 100644 --- a/src/Runner/TestSuiteSorter.php +++ b/src/Runner/TestSuiteSorter.php @@ -15,6 +15,7 @@ use function array_splice; use function assert; use function count; +use function filemtime; use function in_array; use function max; use function shuffle; @@ -27,6 +28,7 @@ use PHPUnit\Runner\ResultCache\NullResultCache; use PHPUnit\Runner\ResultCache\ResultCache; use PHPUnit\Runner\ResultCache\ResultCacheId; +use ReflectionClass; /** * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit @@ -41,6 +43,7 @@ final class TestSuiteSorter public const int ORDER_DEFECTS_FIRST = 3; public const int ORDER_DURATION = 4; public const int ORDER_SIZE = 5; + public const int ORDER_NEWEST = 6; /** * @var non-empty-array @@ -70,6 +73,7 @@ public function reorderTestsInSuite(Test $suite, int $order, bool $resolveDepend self::ORDER_DEFAULT, self::ORDER_REVERSED, self::ORDER_RANDOMIZED, + self::ORDER_NEWEST, self::ORDER_DURATION, self::ORDER_SIZE, ]; @@ -118,6 +122,8 @@ private function sort(TestSuite $suite, int $order, bool $resolveDependencies, i $suite->setTests($this->sortByDuration($suite->tests())); } elseif ($order === self::ORDER_SIZE) { $suite->setTests($this->sortBySize($suite->tests())); + } elseif ($order === self::ORDER_NEWEST) { + $suite->setTests($this->sortByNewest($suite->tests())); } if ($orderDefects === self::ORDER_DEFECTS_FIRST) { @@ -205,6 +211,21 @@ private function sortByDuration(array $tests): array return $tests; } + /** + * @param list $tests + * + * @return list + */ + private function sortByNewest(array $tests): array + { + usort( + $tests, + fn (Test $left, Test $right) => $this->cmpNewest($left, $right), + ); + + return $tests; + } + /** * @param list $tests * @@ -260,6 +281,26 @@ private function cmpDuration(Test $a, Test $b): int return $this->cache->time(ResultCacheId::fromReorderable($a)) <=> $this->cache->time(ResultCacheId::fromReorderable($b)); } + /** + * Compares test modified for sorting by how recent the test is + * Descending order: Newest first. + */ + private function cmpNewest(Test $a, Test $b): int + { + $result = 0; + $fileA = $this->getTestFile($a); + $fileB = $this->getTestFile($b); + + if ($fileA !== null && $fileB !== null) { + $mtimeA = (int) @filemtime($fileA); + $mtimeB = (int) @filemtime($fileB); + + $result = $mtimeB <=> $mtimeA; + } + + return $result; + } + /** * Compares test size for sorting tests small->medium->large->unknown. */ @@ -275,6 +316,27 @@ private function cmpSize(Test $a, Test $b): int return self::SIZE_SORT_WEIGHT[$sizeA] <=> self::SIZE_SORT_WEIGHT[$sizeB]; } + /** + * Helper function for retrieving the test's file. + * Returns the first file if the argument is a TestSuite. + */ + private function getTestFile(Test $test): ?string + { + $result = null; + + if ($test instanceof TestCase) { + $reflection = new ReflectionClass($test); + $filename = $reflection->getFileName(); + $result = $filename !== false ? $filename : null; + } + + if ($test instanceof TestSuite && count($test->tests()) > 0) { + $result = $this->getTestFile($test->tests()[0]); + } + + return $result; + } + /** * Reorder Tests within a TestCase in such a way as to resolve as many dependencies as possible. * The algorithm will leave the tests in original running order when it can. diff --git a/src/TextUI/Configuration/Cli/Builder.php b/src/TextUI/Configuration/Cli/Builder.php index 068a34e39ed..e394c6b28fb 100644 --- a/src/TextUI/Configuration/Cli/Builder.php +++ b/src/TextUI/Configuration/Cli/Builder.php @@ -665,6 +665,11 @@ public function fromParameters(array $parameters): Configuration break; + case 'newest': + $executionOrder = TestSuiteSorter::ORDER_NEWEST; + + break; + case 'no-depends': $resolveDependencies = false; diff --git a/src/TextUI/Configuration/Xml/Loader.php b/src/TextUI/Configuration/Xml/Loader.php index 1cd8ded5aa7..779957f2be3 100644 --- a/src/TextUI/Configuration/Xml/Loader.php +++ b/src/TextUI/Configuration/Xml/Loader.php @@ -898,6 +898,11 @@ private function phpunit(string $filename, DOMDocument $document, DOMXPath $xpat break; + case 'newest': + $executionOrder = TestSuiteSorter::ORDER_NEWEST; + + break; + case 'random': $executionOrder = TestSuiteSorter::ORDER_RANDOMIZED; diff --git a/src/TextUI/Help.php b/src/TextUI/Help.php index 576855c6847..f3c5efcfef4 100644 --- a/src/TextUI/Help.php +++ b/src/TextUI/Help.php @@ -243,7 +243,7 @@ private function elements(): array ['arg' => '--do-not-cache-result', 'desc' => 'Do not write test results to cache file'], ['spacer' => ''], - ['arg' => '--order-by ', 'desc' => 'Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size'], + ['arg' => '--order-by ', 'desc' => 'Run tests in order: default|defects|depends|duration|newest|no-depends|random|reverse|size'], ['arg' => '--random-order-seed ', 'desc' => 'Use the specified random seed when running tests in random order'], ], diff --git a/tests/end-to-end/_files/output-cli-help-color.txt b/tests/end-to-end/_files/output-cli-help-color.txt index 2b4c03d1893..bcebe29d7c4 100644 --- a/tests/end-to-end/_files/output-cli-help-color.txt +++ b/tests/end-to-end/_files/output-cli-help-color.txt @@ -134,7 +134,7 @@ --do-not-cache-result  Do not write test results to cache file --order-by   Run tests in order: - default|defects|depends|duration|no-depends|random|reverse|size + default|defects|depends|duration|newest|no-depends|random|reverse|size --random-order-seed   Use the specified random seed when running tests in random order diff --git a/tests/end-to-end/_files/output-cli-usage.txt b/tests/end-to-end/_files/output-cli-usage.txt index 90409184163..86fcd2cf877 100644 --- a/tests/end-to-end/_files/output-cli-usage.txt +++ b/tests/end-to-end/_files/output-cli-usage.txt @@ -88,7 +88,7 @@ Execution: --cache-result Write test results to cache file --do-not-cache-result Do not write test results to cache file - --order-by Run tests in order: default|defects|depends|duration|no-depends|random|reverse|size + --order-by Run tests in order: default|defects|depends|duration|newest|no-depends|random|reverse|size --random-order-seed Use the specified random seed when running tests in random order Reporting: diff --git a/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/MiddleTest.php b/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/MiddleTest.php new file mode 100644 index 00000000000..68380969de6 --- /dev/null +++ b/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/MiddleTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ExecutionOrder\ModificationTime; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; + +#[CoversNothing] +final class MiddleTest extends TestCase +{ + public function testMiddle(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/NewTest.php b/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/NewTest.php new file mode 100644 index 00000000000..e1a0d869a52 --- /dev/null +++ b/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/NewTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ExecutionOrder\ModificationTime; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; + +#[CoversNothing] +final class NewTest extends TestCase +{ + public function testNew(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/OldTest.php b/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/OldTest.php new file mode 100644 index 00000000000..7315dc2aeff --- /dev/null +++ b/tests/end-to-end/execution-order/fixture/test-classes-with-different-modification-times/OldTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ExecutionOrder\ModificationTime; + +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\TestCase; + +#[CoversNothing] +final class OldTest extends TestCase +{ + public function testOld(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/execution-order/order-by-newest-test-classes.phpt b/tests/end-to-end/execution-order/order-by-newest-test-classes.phpt new file mode 100644 index 00000000000..5dd09e0f373 --- /dev/null +++ b/tests/end-to-end/execution-order/order-by-newest-test-classes.phpt @@ -0,0 +1,52 @@ +--TEST-- +Order by newest: Test classes with different modification times +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit Started (PHPUnit %s using %s) +Test Runner Configured +Event Facade Sealed +Test Suite Loaded (3 tests) +Test Runner Started +Test Suite Sorted +Test Runner Execution Started (3 tests) +Test Suite Started (CLI Arguments, 3 tests) +Test Suite Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest, 1 test) +Test Preparation Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest::testNew) +Test Prepared (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest::testNew) +Test Passed (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest::testNew) +Test Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest::testNew) +Test Suite Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest, 1 test) +Test Suite Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest, 1 test) +Test Preparation Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest::testMiddle) +Test Prepared (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest::testMiddle) +Test Passed (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest::testMiddle) +Test Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest::testMiddle) +Test Suite Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest, 1 test) +Test Suite Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest, 1 test) +Test Preparation Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest::testOld) +Test Prepared (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest::testOld) +Test Passed (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest::testOld) +Test Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest::testOld) +Test Suite Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest, 1 test) +Test Suite Finished (CLI Arguments, 3 tests) +Test Runner Execution Finished +Test Runner Finished +PHPUnit Finished (Shell Exit Code: 0) diff --git a/tests/end-to-end/execution-order/order-by-newest-xml-test-classes.phpt b/tests/end-to-end/execution-order/order-by-newest-xml-test-classes.phpt new file mode 100644 index 00000000000..589cd711b22 --- /dev/null +++ b/tests/end-to-end/execution-order/order-by-newest-xml-test-classes.phpt @@ -0,0 +1,66 @@ +--TEST-- +Order by newest: Test classes with different modification times +--FILE-- + + + + + {$fixture_dir} + + + +XML; + +// XML config must be in a file +$configFile = tempnam(sys_get_temp_dir(), 'pnt'); +file_put_contents($configFile, $xmlConfig); + +$_SERVER['argv'] = ['phpunit', '--configuration', $configFile, '--debug']; + +// Force the modified times to be in order +touch("$fixture_dir/OldTest.php", strtotime('2026-01-01 00:00:00')); +touch("$fixture_dir/MiddleTest.php", strtotime('2026-01-02 00:00:00')); +touch("$fixture_dir/NewTest.php", strtotime('2026-01-03 00:00:00')); + +require $bootstrap_path; + +(new PHPUnit\TextUI\Application)->run($_SERVER['argv']); +--EXPECTF-- +PHPUnit Started (PHPUnit %s using %s) +Test Runner Configured +Bootstrap Finished (%s) +Event Facade Sealed +Test Suite Loaded (3 tests) +Test Runner Started +Test Suite Sorted +Test Runner Execution Started (3 tests) +Test Suite Started (%s, 3 tests) +Test Suite Started (newest-test, 3 tests) +Test Suite Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest, 1 test) +Test Preparation Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest::testNew) +Test Prepared (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest::testNew) +Test Passed (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest::testNew) +Test Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest::testNew) +Test Suite Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\NewTest, 1 test) +Test Suite Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest, 1 test) +Test Preparation Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest::testMiddle) +Test Prepared (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest::testMiddle) +Test Passed (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest::testMiddle) +Test Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest::testMiddle) +Test Suite Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\MiddleTest, 1 test) +Test Suite Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest, 1 test) +Test Preparation Started (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest::testOld) +Test Prepared (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest::testOld) +Test Passed (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest::testOld) +Test Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest::testOld) +Test Suite Finished (PHPUnit\TestFixture\ExecutionOrder\ModificationTime\OldTest, 1 test) +Test Suite Finished (newest-test, 3 tests) +Test Suite Finished (%s, 3 tests) +Test Runner Execution Finished +Test Runner Finished +PHPUnit Finished (Shell Exit Code: 0) diff --git a/tests/unit/TextUI/Configuration/Cli/BuilderTest.php b/tests/unit/TextUI/Configuration/Cli/BuilderTest.php index df35dee41cb..106235830a1 100644 --- a/tests/unit/TextUI/Configuration/Cli/BuilderTest.php +++ b/tests/unit/TextUI/Configuration/Cli/BuilderTest.php @@ -1126,6 +1126,17 @@ public function testOrderByDuration(): void $this->assertFalse($configuration->hasResolveDependencies()); } + #[TestDox('--order-by newest')] + public function testOrderByNewest(): void + { + $configuration = (new Builder)->fromParameters(['--order-by', 'newest']); + + $this->assertTrue($configuration->hasExecutionOrder()); + $this->assertSame(TestSuiteSorter::ORDER_NEWEST, $configuration->executionOrder()); + $this->assertFalse($configuration->hasExecutionOrderDefects()); + $this->assertFalse($configuration->hasResolveDependencies()); + } + #[TestDox('--order-by random')] public function testOrderByRandom(): void { diff --git a/tests/unit/TextUI/Configuration/Xml/LoaderTest.php b/tests/unit/TextUI/Configuration/Xml/LoaderTest.php index ff3902155cc..a56e6c1891e 100644 --- a/tests/unit/TextUI/Configuration/Xml/LoaderTest.php +++ b/tests/unit/TextUI/Configuration/Xml/LoaderTest.php @@ -39,6 +39,7 @@ public static function configurationRootOptionsProvider(): array { return [ 'executionOrder default' => ['executionOrder', 'default', TestSuiteSorter::ORDER_DEFAULT], + 'executionOrder newest' => ['executionOrder', 'newest', TestSuiteSorter::ORDER_NEWEST], 'executionOrder random' => ['executionOrder', 'random', TestSuiteSorter::ORDER_RANDOMIZED], 'executionOrder reverse' => ['executionOrder', 'reverse', TestSuiteSorter::ORDER_REVERSED], 'executionOrder size' => ['executionOrder', 'size', TestSuiteSorter::ORDER_SIZE],