diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc5872e --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# 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 +``` + +Pass the results to PHPUnit: + +```bash +./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/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..f0b194e --- /dev/null +++ b/src/SplitterCommand.php @@ -0,0 +1,67 @@ +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'); + if (\file_exists($bootstrap)) { + 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; + } + +} diff --git a/src/TestMapper.php b/src/TestMapper.php new file mode 100644 index 0000000..17f4c43 --- /dev/null +++ b/src/TestMapper.php @@ -0,0 +1,60 @@ +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']; + 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) { + $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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + +