From 18cc3d01a809c18f3a79a1d6da088a833277d8d9 Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Thu, 14 Sep 2023 14:08:28 +1000 Subject: [PATCH 1/7] WIP --- README.md | 31 ++++++++++ composer.json | 8 ++- phpunit-splitter | 33 +++++++++++ scripts/generate-fixtures.sh | 3 + src/SplitterCommand.php | 1 + src/TestMapper.php | 54 +++++++++++++++++ tests/PhpUnitSplitterTest.php | 55 ++++++++++++++++++ tests/bootstrap.php | 83 +++++++++++++++++++++++++++ tests/fixtures/.phpunit.result.cache | 1 + tests/fixtures/Test/FastTestsTest.php | 36 ++++++++++++ tests/fixtures/Test/ProviderTest.php | 30 ++++++++++ tests/fixtures/Test/SlowTestsTest.php | 36 ++++++++++++ tests/fixtures/tests.xml | 24 ++++++++ 13 files changed, 393 insertions(+), 2 deletions(-) create mode 100644 README.md create mode 100755 phpunit-splitter create mode 100755 scripts/generate-fixtures.sh create mode 100644 src/SplitterCommand.php create mode 100644 src/TestMapper.php create mode 100644 tests/PhpUnitSplitterTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/fixtures/.phpunit.result.cache create mode 100644 tests/fixtures/Test/FastTestsTest.php create mode 100644 tests/fixtures/Test/ProviderTest.php create mode 100644 tests/fixtures/Test/SlowTestsTest.php create mode 100644 tests/fixtures/tests.xml diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e3777e --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# PHPUnit Test Splitter + +Allows you to split your PHPUnit tests by timings. + +## Usage + +Generate a timing file: + +```bash +phpunit --cache-result --cache-result-file=.phpunit.result.cache +``` + +List your tests: + +```bash +phpunit --list-tests-xml=tests.xml +``` + +This generates an XML file with a list of tests. You can add `--testsuite` to limit the tests to a specific suite. + +Split the tests in 2 groups and get the first group (0): + +```bash +phpunit-splitter 2 0 --tests-file=tests.xml --results-file=.phpunit.result.cache +``` + +Split the tests in 4 groups and get the third group (2): + +```bash +phpunit-splitter 4 2 --tests-file=tests.xml --results-file=.phpunit.result.cache +``` diff --git a/composer.json b/composer.json index f40c142..432db01 100644 --- a/composer.json +++ b/composer.json @@ -11,14 +11,18 @@ "type": "project", "require": { "php": "^8.1", + "ext-simplexml": "*", "phpunit/phpunit": "^9.6", - "previousnext/phpunit-finder": "^2.0" + "previousnext/phpunit-finder": "^2.0", + "symfony/console": "^6.3" }, "autoload": { "psr-4": {"PhpUnitSplitter\\": "src/"} }, "autoload-dev": { - "psr-4": {"PhpUnitSplitter\\Tests\\": "tests/"} + "psr-4": { + "PhpUnitSplitter\\Tests\\": "tests/" + } }, "config": { "sort-packages": true diff --git a/phpunit-splitter b/phpunit-splitter new file mode 100755 index 0000000..2d28aaa --- /dev/null +++ b/phpunit-splitter @@ -0,0 +1,33 @@ +#!/usr/bin/env php +add($command); +$application->setDefaultCommand($command->getName(), TRUE); +$application->run(); diff --git a/scripts/generate-fixtures.sh b/scripts/generate-fixtures.sh new file mode 100755 index 0000000..02d4b63 --- /dev/null +++ b/scripts/generate-fixtures.sh @@ -0,0 +1,3 @@ +#!/bin/bash +./vendor/bin/phpunit --list-tests-xml=tests/fixtures/tests.xml tests/fixtures/Test +./vendor/bin/phpunit tests/fixtures/Test --cache-result --cache-result-file=tests/fixtures/.phpunit.result.cache diff --git a/src/SplitterCommand.php b/src/SplitterCommand.php new file mode 100644 index 0000000..02a345c --- /dev/null +++ b/src/SplitterCommand.php @@ -0,0 +1 @@ +addArgument('splits', InputArgument::OPTIONAL, "The number of splits", 1); $this->addArgument('index', InputArgument::OPTIONAL, "The index of the current split", 0); $this->addOption('tests-file', 't', InputOption::VALUE_REQUIRED, "The xml file listing all tests.", getcwd() . './tests.xml'); $this->addOption('results-file', 'f', InputOption::VALUE_REQUIRED, "The results cache file.", getcwd() . '/.phpunit.result.cache', ); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { // @todo validation $splits = (int) $input->getArgument('splits'); $index = (int) $input->getArgument('index'); $testsFile = $input->getOption('tests-file'); $resultsFile = $input->getOption('results-file'); $mapper = new TestMapper($testsFile, $resultsFile); $map = $mapper->sortMap($mapper->getMap()); foreach ($this->split($map, $splits, $index) as $testName => $test) { $output->writeln('--filter "/' . \addslashes($testName) . '$/" ' . $test['path']); } return 0; } private function split(array $map, int $splits, int $index): array { $result = []; $keys = array_keys($map); $values = array_values($map); for ($i = $index; $i < count($map); $i++) { if (($i - $index) % $splits === 0) { $result[$keys[$i]] = $values[$i]; } } return $result; } } \ No newline at end of file diff --git a/src/TestMapper.php b/src/TestMapper.php new file mode 100644 index 0000000..8bd9913 --- /dev/null +++ b/src/TestMapper.php @@ -0,0 +1,54 @@ +testsXml = \simplexml_load_file($testListFilePath); + $this->resultCache = new DefaultTestResultCache($testResultFilePath); + } + + public function getMap(): array { + $this->resultCache->load(); + $map = []; + $classesXpath = $this->testsXml->xpath('//testCaseClass'); + foreach ($classesXpath as $class) { + $className = (string) $class->attributes()['name']; + $reflection = new \ReflectionClass($className); + $filename = $reflection->getFileName(); + $testCases = $class->xpath('testCaseMethod'); + foreach ($testCases as $testCase) { + $testName = $reflection->getShortName() . '::' . $testCase->attributes()['name']; + $dataSet = $testCase->attributes()['dataSet'] ?? NULL; + if ($dataSet !== NULL) { + $testName .= " with data set $dataSet"; + } + $map[$testName] = [ + 'path' => $filename, + 'time' => $this->resultCache->getTime($testName), + ]; + } + } + return $map; + } + + public function sortMap(array $map): array { + uasort($map, function ($a, $b) { + return $a['time'] <=> $b['time']; + }); + return $map; + } + +} diff --git a/tests/PhpUnitSplitterTest.php b/tests/PhpUnitSplitterTest.php new file mode 100644 index 0000000..38de73f --- /dev/null +++ b/tests/PhpUnitSplitterTest.php @@ -0,0 +1,55 @@ +getMap(); + + $this->assertSame([ + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testOne', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testTwo', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testThree', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testFour', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testFive', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set "one"', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set "two"', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set "three"', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set "four"', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set "five"', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testOne', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testTwo', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testThree', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testFour', + 'PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testFive', + ], array_keys($map)); + + $sorted = $mapper->sortMap($map); + $this->assertSame([ + 'PhpUnitSplitter\Tests\fixtures\Test\FastTestsTest::testOne', + 'PhpUnitSplitter\Tests\fixtures\Test\FastTestsTest::testTwo', + 'PhpUnitSplitter\Tests\fixtures\Test\FastTestsTest::testThree', + 'PhpUnitSplitter\Tests\fixtures\Test\FastTestsTest::testFour', + 'PhpUnitSplitter\Tests\fixtures\Test\FastTestsTest::testFive', + 'PhpUnitSplitter\Tests\fixtures\Test\SlowTestsTest::testOne', + 'PhpUnitSplitter\Tests\fixtures\Test\ProviderTest::testProvider with data set "one"', + 'PhpUnitSplitter\Tests\fixtures\Test\SlowTestsTest::testTwo', + 'PhpUnitSplitter\Tests\fixtures\Test\ProviderTest::testProvider with data set "three"', + 'PhpUnitSplitter\Tests\fixtures\Test\SlowTestsTest::testThree', + 'PhpUnitSplitter\Tests\fixtures\Test\ProviderTest::testProvider with data set "four"', + 'PhpUnitSplitter\Tests\fixtures\Test\SlowTestsTest::testFour', + 'PhpUnitSplitter\Tests\fixtures\Test\ProviderTest::testProvider with data set "two"', + 'PhpUnitSplitter\Tests\fixtures\Test\SlowTestsTest::testFive', + 'PhpUnitSplitter\Tests\fixtures\Test\ProviderTest::testProvider with data set "five"', + ], array_keys($sorted)); + + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..1294847 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,83 @@ +add('Drupal\\Tests', __DIR__ . '/../vendor/drupal/core/tests'); +$loader->add('Drupal\\KernelTests', __DIR__ . '/../vendor/drupal/core/tests'); +$loader->add('Drupal\\FunctionalTests', __DIR__ . '/../vendor/drupal/core/tests'); +$loader->add('Drupal\\FunctionalJavascriptTests', __DIR__ . '/../vendor/drupal/core/tests'); +$loader->add('Drupal\\TestTools', __DIR__ . '/../vendor/drupal/core/tests'); +class_exists('phpDocumentor\Reflection\DocBlockFactory'); + +if (!isset($GLOBALS['namespaces'])) { + // Scan for arbitrary extension namespaces from core and contrib. + $core = __DIR__ . '/../vendor/drupal/core'; + $extension_roots = [ + $core, + ]; + + $dirs = array_map(static function ($scan_directory) { + $extensions = []; + $filter = new RecursiveExtensionFilterIterator(new \RecursiveDirectoryIterator($scan_directory, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS | \FilesystemIterator::CURRENT_AS_SELF), []); + $filter->acceptTests(TRUE); + $dirs = new \RecursiveIteratorIterator($filter); + foreach ($dirs as $dir) { + if (strpos($dir->getPathname(), '.info.yml') !== FALSE) { + // Cut off ".info.yml" from the filename for use as the extension name. + // We use getRealPath() so that we can scan extensions represented by + // directory aliases. + $extensions[substr($dir->getFilename(), 0, -9)] = $dir->getPathInfo() + ->getRealPath(); + } + } + return $extensions; + }, $extension_roots); + $dirs = array_reduce($dirs, 'array_merge', []); + $namespaces = []; + foreach ($dirs as $extension => $dir) { + if (is_dir($dir . '/src')) { + // Register the PSR-4 directory for module-provided classes. + $namespaces['Drupal\\' . $extension . '\\'][] = $dir . '/src'; + } + if (is_dir($dir . '/tests/src')) { + // Register the PSR-4 directory for PHPUnit test classes. + $namespaces['Drupal\\Tests\\' . $extension . '\\'][] = $dir . '/tests/src'; + } + } + $GLOBALS['namespaces'] = $namespaces; +} +foreach ($GLOBALS['namespaces'] as $prefix => $paths) { + $loader->addPsr4($prefix, $paths); +} + +// @see https://www.drupal.org/node/3113653, and cores bootstrap.php. +ClassWriter::mutateTestBase($loader); + +date_default_timezone_set('Australia/Sydney'); + +assert_options(ASSERT_ACTIVE, TRUE); +assert_options(ASSERT_EXCEPTION, TRUE); + +// See https://symfony.com/doc/current/configuration.html#overriding-environment-values-via-env-local +// if you want to customise! +(new Dotenv())->usePutenv()->loadEnv(__DIR__ . '/../.env.dist'); +(new Dotenv())->usePutenv()->loadEnv(__DIR__ . '/../.env'); diff --git a/tests/fixtures/.phpunit.result.cache b/tests/fixtures/.phpunit.result.cache new file mode 100644 index 0000000..b104715 --- /dev/null +++ b/tests/fixtures/.phpunit.result.cache @@ -0,0 +1 @@ +{"version":1,"defects":[],"times":{"PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testOne":0.012,"PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testTwo":0.02,"PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testThree":0.03,"PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testFour":0.04,"PhpUnitSplitter\\Tests\\fixtures\\Test\\FastTestsTest::testFive":0.05,"PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testOne":0.1,"PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testTwo":0.2,"PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testThree":0.3,"PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testFour":0.4,"PhpUnitSplitter\\Tests\\fixtures\\Test\\SlowTestsTest::testFive":0.5,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set #0":1,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set #1":0.111,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set #2":0.445,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set #3":0.222,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set #4":0.334,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set #5":0.667,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set \"one\"":0.111,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set \"two\"":0.445,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set \"three\"":0.222,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set \"four\"":0.334,"PhpUnitSplitter\\Tests\\fixtures\\Test\\ProviderTest::testProvider with data set \"five\"":0.667}} \ No newline at end of file diff --git a/tests/fixtures/Test/FastTestsTest.php b/tests/fixtures/Test/FastTestsTest.php new file mode 100644 index 0000000..a828257 --- /dev/null +++ b/tests/fixtures/Test/FastTestsTest.php @@ -0,0 +1,36 @@ +assertTrue(TRUE); + } + + function testTwo(): void { + usleep(20000); + $this->assertTrue(TRUE); + } + + function testThree(): void { + usleep(30000); + $this->assertTrue(TRUE); + } + + function testFour(): void { + usleep(40000); + $this->assertTrue(TRUE); + } + + function testFive(): void { + usleep(50000); + $this->assertTrue(TRUE); + } + +} diff --git a/tests/fixtures/Test/ProviderTest.php b/tests/fixtures/Test/ProviderTest.php new file mode 100644 index 0000000..84f452a --- /dev/null +++ b/tests/fixtures/Test/ProviderTest.php @@ -0,0 +1,30 @@ +assertTrue(TRUE); + } + + public function provider(): array { + return [ + 'one' => [111111], + 'two' => [444444], + 'three' => [222222], + 'four' => [333333], + 'five' => [666666], + ]; + } + + +} diff --git a/tests/fixtures/Test/SlowTestsTest.php b/tests/fixtures/Test/SlowTestsTest.php new file mode 100644 index 0000000..aff68ab --- /dev/null +++ b/tests/fixtures/Test/SlowTestsTest.php @@ -0,0 +1,36 @@ +assertTrue(TRUE); + } + + function testTwo(): void { + usleep(200000); + $this->assertTrue(TRUE); + } + + function testThree(): void { + usleep(300000); + $this->assertTrue(TRUE); + } + + function testFour(): void { + usleep(400000); + $this->assertTrue(TRUE); + } + + function testFive(): void { + usleep(500000); + $this->assertTrue(TRUE); + } + +} diff --git a/tests/fixtures/tests.xml b/tests/fixtures/tests.xml new file mode 100644 index 0000000..e768471 --- /dev/null +++ b/tests/fixtures/tests.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + From 7af1d68e8261c7a02bf131418f03a663ebc76be9 Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Thu, 14 Sep 2023 14:48:55 +1000 Subject: [PATCH 2/7] Allow specifying bootstrap file --- src/SplitterCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SplitterCommand.php b/src/SplitterCommand.php index 02a345c..ba250cb 100644 --- a/src/SplitterCommand.php +++ b/src/SplitterCommand.php @@ -1 +1 @@ -addArgument('splits', InputArgument::OPTIONAL, "The number of splits", 1); $this->addArgument('index', InputArgument::OPTIONAL, "The index of the current split", 0); $this->addOption('tests-file', 't', InputOption::VALUE_REQUIRED, "The xml file listing all tests.", getcwd() . './tests.xml'); $this->addOption('results-file', 'f', InputOption::VALUE_REQUIRED, "The results cache file.", getcwd() . '/.phpunit.result.cache', ); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { // @todo validation $splits = (int) $input->getArgument('splits'); $index = (int) $input->getArgument('index'); $testsFile = $input->getOption('tests-file'); $resultsFile = $input->getOption('results-file'); $mapper = new TestMapper($testsFile, $resultsFile); $map = $mapper->sortMap($mapper->getMap()); foreach ($this->split($map, $splits, $index) as $testName => $test) { $output->writeln('--filter "/' . \addslashes($testName) . '$/" ' . $test['path']); } return 0; } private function split(array $map, int $splits, int $index): array { $result = []; $keys = array_keys($map); $values = array_values($map); for ($i = $index; $i < count($map); $i++) { if (($i - $index) % $splits === 0) { $result[$keys[$i]] = $values[$i]; } } return $result; } } \ No newline at end of file +addArgument('splits', InputArgument::OPTIONAL, "The number of splits", 1); $this->addArgument('index', InputArgument::OPTIONAL, "The index of the current split", 0); $this->addOption('tests-file', 't', InputOption::VALUE_REQUIRED, "The xml file listing all tests.", getcwd() . './tests.xml'); $this->addOption('results-file', 'f', InputOption::VALUE_REQUIRED, "The results cache file.", getcwd() . '/.phpunit.result.cache', ); $this->addOption('bootstrap-file', 'b', InputOption::VALUE_OPTIONAL, "The tests bootstrap file.", getcwd() . '/tests/bootstrap.php'); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { $bootstrap = $input->getOption('bootstrap-file'); include_once $bootstrap; // @todo validation $splits = (int) $input->getArgument('splits'); $index = (int) $input->getArgument('index'); $testsFile = $input->getOption('tests-file'); $resultsFile = $input->getOption('results-file'); $mapper = new TestMapper($testsFile, $resultsFile); $map = $mapper->sortMap($mapper->getMap()); foreach ($this->split($map, $splits, $index) as $testName => $test) { $output->writeln('--filter "/' . \addslashes($testName) . '$/" ' . $test['path']); } return 0; } private function split(array $map, int $splits, int $index): array { $result = []; $keys = array_keys($map); $values = array_values($map); for ($i = $index; $i < count($map); $i++) { if (($i - $index) % $splits === 0) { $result[$keys[$i]] = $values[$i]; } } return $result; } } \ No newline at end of file From f977a7e6abdb389145cb0811382c3556f87fd758 Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Thu, 14 Sep 2023 14:49:23 +1000 Subject: [PATCH 3/7] Skip if the class can't be found --- src/TestMapper.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/TestMapper.php b/src/TestMapper.php index 8bd9913..17f4c43 100644 --- a/src/TestMapper.php +++ b/src/TestMapper.php @@ -26,7 +26,13 @@ public function getMap(): array { $classesXpath = $this->testsXml->xpath('//testCaseClass'); foreach ($classesXpath as $class) { $className = (string) $class->attributes()['name']; - $reflection = new \ReflectionClass($className); + try { + $reflection = new \ReflectionClass($className); + } + catch (\ReflectionException $e) { + // Couldn't find the class. + continue; + } $filename = $reflection->getFileName(); $testCases = $class->xpath('testCaseMethod'); foreach ($testCases as $testCase) { From b68522e2d034957a98cb3605772c544de57f96ad Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Thu, 14 Sep 2023 14:50:23 +1000 Subject: [PATCH 4/7] Change output format --- README.md | 6 ++++++ src/SplitterCommand.php | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e3777e..902268c 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,9 @@ Split the tests in 4 groups and get the third group (2): ```bash phpunit-splitter 4 2 --tests-file=tests.xml --results-file=.phpunit.result.cache ``` + +Pass the results to PHPUnit: + +```bash +phpunit-splitter 1 0 | xargs -n 2 sh -c './bin/phpunit --filter="/$0$/" "$1"' +``` diff --git a/src/SplitterCommand.php b/src/SplitterCommand.php index ba250cb..9dc3d38 100644 --- a/src/SplitterCommand.php +++ b/src/SplitterCommand.php @@ -1 +1 @@ -addArgument('splits', InputArgument::OPTIONAL, "The number of splits", 1); $this->addArgument('index', InputArgument::OPTIONAL, "The index of the current split", 0); $this->addOption('tests-file', 't', InputOption::VALUE_REQUIRED, "The xml file listing all tests.", getcwd() . './tests.xml'); $this->addOption('results-file', 'f', InputOption::VALUE_REQUIRED, "The results cache file.", getcwd() . '/.phpunit.result.cache', ); $this->addOption('bootstrap-file', 'b', InputOption::VALUE_OPTIONAL, "The tests bootstrap file.", getcwd() . '/tests/bootstrap.php'); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { $bootstrap = $input->getOption('bootstrap-file'); include_once $bootstrap; // @todo validation $splits = (int) $input->getArgument('splits'); $index = (int) $input->getArgument('index'); $testsFile = $input->getOption('tests-file'); $resultsFile = $input->getOption('results-file'); $mapper = new TestMapper($testsFile, $resultsFile); $map = $mapper->sortMap($mapper->getMap()); foreach ($this->split($map, $splits, $index) as $testName => $test) { $output->writeln('--filter "/' . \addslashes($testName) . '$/" ' . $test['path']); } return 0; } private function split(array $map, int $splits, int $index): array { $result = []; $keys = array_keys($map); $values = array_values($map); for ($i = $index; $i < count($map); $i++) { if (($i - $index) % $splits === 0) { $result[$keys[$i]] = $values[$i]; } } return $result; } } \ No newline at end of file +addArgument('splits', InputArgument::OPTIONAL, "The number of splits", 1); $this->addArgument('index', InputArgument::OPTIONAL, "The index of the current split", 0); $this->addOption('tests-file', 't', InputOption::VALUE_REQUIRED, "The xml file listing all tests.", getcwd() . './tests.xml'); $this->addOption('results-file', 'f', InputOption::VALUE_REQUIRED, "The results cache file.", getcwd() . '/.phpunit.result.cache', ); $this->addOption('bootstrap-file', 'b', InputOption::VALUE_OPTIONAL, "The tests bootstrap file.", getcwd() . '/tests/bootstrap.php'); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { $bootstrap = $input->getOption('bootstrap-file'); include_once $bootstrap; // @todo validation $splits = (int) $input->getArgument('splits'); $index = (int) $input->getArgument('index'); $testsFile = $input->getOption('tests-file'); $resultsFile = $input->getOption('results-file'); $mapper = new TestMapper($testsFile, $resultsFile); $map = $mapper->sortMap($mapper->getMap()); foreach ($this->split($map, $splits, $index) as $testName => $test) { $output->writeln("'" . \addslashes($testName) . "'" . ' ' . $test['path']); } return 0; } private function split(array $map, int $splits, int $index): array { $result = []; $keys = array_keys($map); $values = array_values($map); for ($i = $index; $i < count($map); $i++) { if (($i - $index) % $splits === 0) { $result[$keys[$i]] = $values[$i]; } } return $result; } } \ No newline at end of file From 86d8aa96685db5f2a6c120a7f6b4dd1e57e7773c Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Thu, 14 Sep 2023 14:57:26 +1000 Subject: [PATCH 5/7] Fix line endings and unused use statements --- src/SplitterCommand.php | 66 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/SplitterCommand.php b/src/SplitterCommand.php index 9dc3d38..c468704 100644 --- a/src/SplitterCommand.php +++ b/src/SplitterCommand.php @@ -1 +1,65 @@ -addArgument('splits', InputArgument::OPTIONAL, "The number of splits", 1); $this->addArgument('index', InputArgument::OPTIONAL, "The index of the current split", 0); $this->addOption('tests-file', 't', InputOption::VALUE_REQUIRED, "The xml file listing all tests.", getcwd() . './tests.xml'); $this->addOption('results-file', 'f', InputOption::VALUE_REQUIRED, "The results cache file.", getcwd() . '/.phpunit.result.cache', ); $this->addOption('bootstrap-file', 'b', InputOption::VALUE_OPTIONAL, "The tests bootstrap file.", getcwd() . '/tests/bootstrap.php'); } /** * {@inheritdoc} */ protected function execute(InputInterface $input, OutputInterface $output): int { $bootstrap = $input->getOption('bootstrap-file'); include_once $bootstrap; // @todo validation $splits = (int) $input->getArgument('splits'); $index = (int) $input->getArgument('index'); $testsFile = $input->getOption('tests-file'); $resultsFile = $input->getOption('results-file'); $mapper = new TestMapper($testsFile, $resultsFile); $map = $mapper->sortMap($mapper->getMap()); foreach ($this->split($map, $splits, $index) as $testName => $test) { $output->writeln("'" . \addslashes($testName) . "'" . ' ' . $test['path']); } return 0; } private function split(array $map, int $splits, int $index): array { $result = []; $keys = array_keys($map); $values = array_values($map); for ($i = $index; $i < count($map); $i++) { if (($i - $index) % $splits === 0) { $result[$keys[$i]] = $values[$i]; } } return $result; } } \ No newline at end of file +addArgument('splits', InputArgument::OPTIONAL, "The number of splits", 1); + $this->addArgument('index', InputArgument::OPTIONAL, "The index of the current split", 0); + $this->addOption('tests-file', 't', InputOption::VALUE_REQUIRED, "The xml file listing all tests.", getcwd() . './tests.xml'); + $this->addOption('results-file', 'f', InputOption::VALUE_REQUIRED, "The results cache file.", getcwd() . '/.phpunit.result.cache', ); + $this->addOption('bootstrap-file', 'b', InputOption::VALUE_OPTIONAL, "The tests bootstrap file.", getcwd() . '/tests/bootstrap.php'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $bootstrap = $input->getOption('bootstrap-file'); + include_once $bootstrap; + // @todo validation + $splits = (int) $input->getArgument('splits'); + $index = (int) $input->getArgument('index'); + $testsFile = $input->getOption('tests-file'); + $resultsFile = $input->getOption('results-file'); + + $mapper = new TestMapper($testsFile, $resultsFile); + $map = $mapper->sortMap($mapper->getMap()); + + foreach ($this->split($map, $splits, $index) as $testName => $test) { + $output->writeln("'" . \addslashes($testName) . "'" . ' ' . $test['path']); + } + + return 0; + } + + private function split(array $map, int $splits, int $index): array { + $result = []; + $keys = array_keys($map); + $values = array_values($map); + + for ($i = $index; $i < count($map); $i++) { + if (($i - $index) % $splits === 0) { + $result[$keys[$i]] = $values[$i]; + } + } + + return $result; + } + +} From f34d0a68381605eccad0e58f9846bb3647a35c9b Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Thu, 14 Sep 2023 14:59:37 +1000 Subject: [PATCH 6/7] Remove bootstrap file --- src/SplitterCommand.php | 4 +- tests/bootstrap.php | 83 ----------------------------------------- 2 files changed, 3 insertions(+), 84 deletions(-) delete mode 100644 tests/bootstrap.php diff --git a/src/SplitterCommand.php b/src/SplitterCommand.php index c468704..d4cf6a8 100644 --- a/src/SplitterCommand.php +++ b/src/SplitterCommand.php @@ -31,7 +31,9 @@ protected function configure(): void { */ protected function execute(InputInterface $input, OutputInterface $output): int { $bootstrap = $input->getOption('bootstrap-file'); - include_once $bootstrap; + if (\file_exists($bootstrap)) { + include_once $bootstrap; + } // @todo validation $splits = (int) $input->getArgument('splits'); $index = (int) $input->getArgument('index'); diff --git a/tests/bootstrap.php b/tests/bootstrap.php deleted file mode 100644 index 1294847..0000000 --- a/tests/bootstrap.php +++ /dev/null @@ -1,83 +0,0 @@ -add('Drupal\\Tests', __DIR__ . '/../vendor/drupal/core/tests'); -$loader->add('Drupal\\KernelTests', __DIR__ . '/../vendor/drupal/core/tests'); -$loader->add('Drupal\\FunctionalTests', __DIR__ . '/../vendor/drupal/core/tests'); -$loader->add('Drupal\\FunctionalJavascriptTests', __DIR__ . '/../vendor/drupal/core/tests'); -$loader->add('Drupal\\TestTools', __DIR__ . '/../vendor/drupal/core/tests'); -class_exists('phpDocumentor\Reflection\DocBlockFactory'); - -if (!isset($GLOBALS['namespaces'])) { - // Scan for arbitrary extension namespaces from core and contrib. - $core = __DIR__ . '/../vendor/drupal/core'; - $extension_roots = [ - $core, - ]; - - $dirs = array_map(static function ($scan_directory) { - $extensions = []; - $filter = new RecursiveExtensionFilterIterator(new \RecursiveDirectoryIterator($scan_directory, \RecursiveDirectoryIterator::FOLLOW_SYMLINKS | \FilesystemIterator::CURRENT_AS_SELF), []); - $filter->acceptTests(TRUE); - $dirs = new \RecursiveIteratorIterator($filter); - foreach ($dirs as $dir) { - if (strpos($dir->getPathname(), '.info.yml') !== FALSE) { - // Cut off ".info.yml" from the filename for use as the extension name. - // We use getRealPath() so that we can scan extensions represented by - // directory aliases. - $extensions[substr($dir->getFilename(), 0, -9)] = $dir->getPathInfo() - ->getRealPath(); - } - } - return $extensions; - }, $extension_roots); - $dirs = array_reduce($dirs, 'array_merge', []); - $namespaces = []; - foreach ($dirs as $extension => $dir) { - if (is_dir($dir . '/src')) { - // Register the PSR-4 directory for module-provided classes. - $namespaces['Drupal\\' . $extension . '\\'][] = $dir . '/src'; - } - if (is_dir($dir . '/tests/src')) { - // Register the PSR-4 directory for PHPUnit test classes. - $namespaces['Drupal\\Tests\\' . $extension . '\\'][] = $dir . '/tests/src'; - } - } - $GLOBALS['namespaces'] = $namespaces; -} -foreach ($GLOBALS['namespaces'] as $prefix => $paths) { - $loader->addPsr4($prefix, $paths); -} - -// @see https://www.drupal.org/node/3113653, and cores bootstrap.php. -ClassWriter::mutateTestBase($loader); - -date_default_timezone_set('Australia/Sydney'); - -assert_options(ASSERT_ACTIVE, TRUE); -assert_options(ASSERT_EXCEPTION, TRUE); - -// See https://symfony.com/doc/current/configuration.html#overriding-environment-values-via-env-local -// if you want to customise! -(new Dotenv())->usePutenv()->loadEnv(__DIR__ . '/../.env.dist'); -(new Dotenv())->usePutenv()->loadEnv(__DIR__ . '/../.env'); From 796bb6c87332b84bab842b0087268e94c66ab59a Mon Sep 17 00:00:00 2001 From: Michael Strelan Date: Thu, 14 Sep 2023 15:28:01 +1000 Subject: [PATCH 7/7] Simplify the output further --- README.md | 6 +++++- src/SplitterCommand.php | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 902268c..fc5872e 100644 --- a/README.md +++ b/README.md @@ -33,5 +33,9 @@ phpunit-splitter 4 2 --tests-file=tests.xml --results-file=.phpunit.result.cache Pass the results to PHPUnit: ```bash -phpunit-splitter 1 0 | xargs -n 2 sh -c './bin/phpunit --filter="/$0$/" "$1"' +./phpunit-splitter 2 0 --tests-file=tests/fixtures/tests.xml --results-file=tests/fixtures/.phpunit.result.cache | while IFS= read -r line; do + filepath=$(echo "$line" | awk '{print $NF}') + testname=$(echo "$line" | awk '{$NF=""; print $0}') + ./vendor/bin/phpunit --filter="$testname" $filepath +done ``` diff --git a/src/SplitterCommand.php b/src/SplitterCommand.php index d4cf6a8..f0b194e 100644 --- a/src/SplitterCommand.php +++ b/src/SplitterCommand.php @@ -44,7 +44,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $map = $mapper->sortMap($mapper->getMap()); foreach ($this->split($map, $splits, $index) as $testName => $test) { - $output->writeln("'" . \addslashes($testName) . "'" . ' ' . $test['path']); + $output->writeln(\addslashes($testName) . ' ' . $test['path']); } return 0;