diff --git a/src/Runner/Filter/ChunkIterator.php b/src/Runner/Filter/ChunkIterator.php new file mode 100644 index 00000000000..75e2fb574f4 --- /dev/null +++ b/src/Runner/Filter/ChunkIterator.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Filter; + +use InvalidArgumentException; +use PHPUnit\Framework\TestSuite; +use PHPUnit\Framework\WarningTestCase; +use PHPUnit\Util\RegularExpression; +use RecursiveFilterIterator; +use RecursiveIterator; + +/** + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class ChunkIterator extends RecursiveFilterIterator +{ + /** + * @var int + */ + private $chunk; + + /** + * @var int + */ + private $numChunks; + + /** + * @var Test[][] + */ + private $chunks; + + /** + * @throws \Exception + */ + public function __construct(RecursiveIterator $iterator, array $args) + { + parent::__construct($iterator); + + $this->chunk = $args['chunk']; + $this->numChunks = $args['numChunks']; + + if ($this->chunk > $this->numChunks) { + throw new InvalidArgumentException(sprintf('You only configured %s chunks but passed chunk %s', + $this->numChunks, + $this->chunk + )); + } + + $tests = iterator_to_array($iterator); + $numTests = count($tests); + $numTestsPerChunk = (int) round($numTests / $this->numChunks); + + $this->chunks = array_chunk(iterator_to_array($iterator), $numTestsPerChunk); + } + + public function accept(): bool + { + $chunkKey = $this->chunk - 1; + + if (!isset($this->chunks[$chunkKey])) { + return false; + } + + $chunk = $this->chunks[$chunkKey]; + + $test = $this->getInnerIterator()->current(); + + return in_array($test, $chunk, true); + } + +} diff --git a/src/TextUI/Command.php b/src/TextUI/Command.php index c97a8af5bb7..c3d3ea2b72a 100644 --- a/src/TextUI/Command.php +++ b/src/TextUI/Command.php @@ -145,6 +145,8 @@ class Command 'version' => null, 'whitelist=' => null, 'dump-xdebug-filter=' => null, + 'chunk' => null, + 'num-chunks' => null, ]; /** @@ -765,6 +767,16 @@ protected function handleArguments(array $argv): void break; + case '--chunk': + $this->arguments['chunk'] = (int) $option[1]; + + break; + + case '--num-chunks': + $this->arguments['num-chunks'] = (int) $option[1]; + + break; + default: $optionName = \str_replace('--', '', $option[0]); diff --git a/src/TextUI/TestRunner.php b/src/TextUI/TestRunner.php index 4e84a7da81f..73c308fc357 100644 --- a/src/TextUI/TestRunner.php +++ b/src/TextUI/TestRunner.php @@ -1248,6 +1248,16 @@ private function processSuiteFilters(TestSuite $suite, array $arguments): void ); } + if (isset($arguments['chunk']) && $arguments['chunk']) { + $filterFactory->addFilter( + new ReflectionClass(ChunkIterator::class), + [ + 'chunk' => $arguments['chunk'], + 'numChunks' => $arguments['num-chunk'] ?? 2, + ] + ); + } + $suite->injectFilter($filterFactory); } diff --git a/tests/unit/Runner/Filter/ChunkIteratorTest.php b/tests/unit/Runner/Filter/ChunkIteratorTest.php new file mode 100644 index 00000000000..9cef8cd5d64 --- /dev/null +++ b/tests/unit/Runner/Filter/ChunkIteratorTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Filter; + +use InvalidArgumentException; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestSuite; + +class ChunkIteratorTest extends TestCase +{ + public function testChunkOne(): void + { + $tests = $this->getTestsChunk(1, 2); + + $this->assertTrue(in_array(TestCase1Test::class.'::testTest', $tests)); + $this->assertTrue(in_array(TestCase2Test::class.'::testTest', $tests)); + $this->assertTrue(in_array(TestCase3Test::class.'::testTest', $tests)); + + $this->assertFalse(in_array(TestCase4Test::class.'::testTest', $tests)); + $this->assertFalse(in_array(TestCase5Test::class.'::testTest', $tests)); + } + + public function testChunkTwo(): void + { + $tests = $this->getTestsChunk(2, 2); + + $this->assertFalse(in_array(TestCase1Test::class.'::testTest', $tests)); + $this->assertFalse(in_array(TestCase2Test::class.'::testTest', $tests)); + $this->assertFalse(in_array(TestCase3Test::class.'::testTest', $tests)); + + $this->assertTrue(in_array(TestCase4Test::class.'::testTest', $tests)); + $this->assertTrue(in_array(TestCase5Test::class.'::testTest', $tests)); + } + + public function testInvalidChunk(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('You only configured 2 chunks but passed chunk 3'); + + $tests = $this->getTestsChunk(3, 2); + } + + /** + * @return TestCase[] + */ + private function getTestsChunk(int $chunk, int $numChunks): array + { + $suite = new TestSuite; + $suite->addTest(new TestCase1Test('testTest')); + $suite->addTest(new TestCase2Test('testTest')); + $suite->addTest(new TestCase3Test('testTest')); + $suite->addTest(new TestCase4Test('testTest')); + $suite->addTest(new TestCase5Test('testTest')); + + $iterator = new ChunkIterator($suite->getIterator(), [ + 'chunk' => $chunk, + 'numChunks' => $numChunks, + ]); + + $iterator->rewind(); + + $tests = []; + + foreach ($iterator as $test) { + $tests[] = get_class($test).'::'.$test->getName(); + } + + return $tests; + } +} + +class TestCase1Test extends TestCase +{ + public function testTest() : void + { + } +} + +class TestCase2Test extends TestCase +{ + public function testTest() : void + { + } +} + +class TestCase3Test extends TestCase +{ + public function testTest() : void + { + } +} + +class TestCase4Test extends TestCase +{ + public function testTest() : void + { + } +} + +class TestCase5Test extends TestCase +{ + public function testTest() : void + { + } +}