Skip to content

Commit dc98869

Browse files
kimpeppermstrelan
andauthored
aggregate classes (#1)
--------- Co-authored-by: Michael Strelan <mstrelan@gmail.com> Co-authored-by: Michael Strelan <michael.strelan@previousnext.com.au>
1 parent 0fb08bd commit dc98869

17 files changed

+505
-4
lines changed

.github/workflows/build.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: 🏗 Build
2+
3+
on:
4+
pull_request:
5+
types: [ synchronize, opened, reopened, ready_for_review ]
6+
push:
7+
branches: [ main ]
8+
9+
permissions:
10+
checks: write
11+
pull-requests: write
12+
13+
jobs:
14+
build:
15+
runs-on: ubuntu-latest
16+
strategy:
17+
matrix:
18+
prefer_lowest: ["", "--prefer-lowest"]
19+
container:
20+
image: skpr/php-cli:${{ inputs.php_cli_tag }}
21+
options:
22+
--pull always
23+
--user 1001:1001
24+
steps:
25+
- name: Checkout
26+
uses: actions/checkout@v4
27+
with:
28+
show-progress: false
29+
- name: 📦 Composer Install
30+
run: composer install --prefer-dist --no-progress --no-interaction ${{ matrix.prefer_lowest }}
31+
- name: 🧹 PHPStan
32+
run: ./bin/phpstan --error-format=github analyse
33+
- name: ⚡ Run Tests
34+
run: ./bin/phpunit --log-junit phpunit-results.xml
35+
- name: 📝 Publish Test Results
36+
uses: EnricoMi/publish-unit-test-result-action@v2
37+
if: always()
38+
with:
39+
files: phpunit-results.xml

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
/bin/
12
/vendor/
23
/.phpunit.result.cache
34
/composer.lock

.phpunit.cache/test-results

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"version":1,"defects":{"PhpUnitSplitter\\Tests\\PhpUnitSplitterTest::testSplitter":5},"times":{"PhpUnitSplitter\\Tests\\PhpUnitSplitterTest::testSplitter":0.001}}

README.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# PHPUnit Test Splitter
2+
3+
Allows you to split your PHPUnit tests by timings.
4+
5+
## Usage
6+
7+
Generate a timing file:
8+
9+
```bash
10+
phpunit --cache-result --cache-result-file=.phpunit.result.cache
11+
```
12+
13+
List your tests:
14+
15+
```bash
16+
phpunit --list-tests-xml=tests.xml
17+
```
18+
19+
This generates an XML file with a list of tests. You can add `--testsuite` to limit the tests to a specific suite.
20+
21+
Split the tests in 2 groups and get the first group (0):
22+
23+
```bash
24+
phpunit-splitter 2 0 --tests-file=tests.xml --results-file=.phpunit.result.cache
25+
```
26+
27+
Split the tests in 4 groups and get the third group (2):
28+
29+
```bash
30+
phpunit-splitter 4 2 --tests-file=tests.xml --results-file=.phpunit.result.cache
31+
```
32+
33+
Pass the results to PHPUnit:
34+
35+
```bash
36+
./phpunit-splitter 2 0 --tests-file=tests/fixtures/tests.xml --results-file=tests/fixtures/.phpunit.result.cache | xargs ./vendor/bin/phpunit
37+
done
38+
```
39+
40+
Output the test list as JSON:
41+
42+
```bash
43+
./phpunit-splitter 2 0 --json --tests-file=tests/fixtures/tests.xml --results-file=tests/fixtures/.phpunit.result.cache
44+
```

composer.json

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,24 @@
1111
"type": "project",
1212
"require": {
1313
"php": "^8.1",
14-
"phpunit/phpunit": "^9.6",
15-
"previousnext/phpunit-finder": "^2.0"
14+
"ext-simplexml": "*",
15+
"symfony/console": "^6.3"
16+
},
17+
"require-dev": {
18+
"phpstan/phpstan": "^1.10",
19+
"phpunit/phpunit": "^9.6"
1620
},
1721
"autoload": {
1822
"psr-4": {"PhpUnitSplitter\\": "src/"}
1923
},
2024
"autoload-dev": {
21-
"psr-4": {"PhpUnitSplitter\\Tests\\": "tests/"}
25+
"psr-4": {
26+
"PhpUnitSplitter\\Tests\\": "tests/src",
27+
"PhpUnitSplitter\\Tests\\Fixtures\\": "tests/fixtures/src"
28+
}
2229
},
2330
"config": {
24-
"sort-packages": true
31+
"sort-packages": true,
32+
"bin-dir": "bin"
2533
}
2634
}

phpstan.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
parameters:
2+
level: 6
3+
paths:
4+
- src
5+
- tests/src

phpunit-splitter

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env php
2+
<?php
3+
/**
4+
* @file
5+
* Console application for PHPUnit Splitter.
6+
*/
7+
8+
$autoload = [
9+
__DIR__ . '/vendor/autoload.php',
10+
dirname(__DIR__, 1) . '/vendor/autoload.php',
11+
dirname(__DIR__, 2) . '/vendor/autoload.php',
12+
dirname(__DIR__, 1) . '/autoload.php',
13+
dirname(__DIR__, 2) . '/autoload.php',
14+
];
15+
foreach ($autoload as $file) {
16+
if (file_exists($file)) {
17+
require $file;
18+
break;
19+
}
20+
}
21+
22+
const APP_NAME = 'PHPUnit Splitter';
23+
const VERSION = '1.x-dev';
24+
25+
use PhpUnitSplitter\SplitterCommand;
26+
use Symfony\Component\Console\Application;
27+
28+
29+
$application = new Application(APP_NAME, VERSION);
30+
$command = new SplitterCommand('phpunit-splitter');
31+
$application->add($command);
32+
$application->setDefaultCommand($command->getName(), TRUE);
33+
$application->run();

phpunit.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
4+
bootstrap="vendor/autoload.php"
5+
cacheResultFile=".phpunit.cache/test-results"
6+
executionOrder="depends,defects"
7+
forceCoversAnnotation="true"
8+
beStrictAboutCoversAnnotation="true"
9+
beStrictAboutOutputDuringTests="true"
10+
beStrictAboutTodoAnnotatedTests="true"
11+
convertDeprecationsToExceptions="true"
12+
failOnRisky="true"
13+
failOnWarning="true"
14+
verbose="true">
15+
<testsuites>
16+
<testsuite name="default">
17+
<directory>tests/src</directory>
18+
</testsuite>
19+
</testsuites>
20+
21+
<coverage cacheDirectory=".phpunit.cache/code-coverage"
22+
processUncoveredFiles="true">
23+
<include>
24+
<directory suffix=".php">src</directory>
25+
</include>
26+
</coverage>
27+
</phpunit>

scripts/generate-fixtures.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/bash
2+
./vendor/bin/phpunit --list-tests-xml=tests/fixtures/tests.xml tests/fixtures/src
3+
./vendor/bin/phpunit tests/fixtures/src --cache-result --cache-result-file=tests/fixtures/.phpunit.result.cache

src/SplitterCommand.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpUnitSplitter;
6+
7+
use Symfony\Component\Console\Command\Command;
8+
use Symfony\Component\Console\Input\InputArgument;
9+
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Input\InputOption;
11+
use Symfony\Component\Console\Output\OutputInterface;
12+
13+
/**
14+
* A symfony command for splitting PHPUnit tests.
15+
*/
16+
class SplitterCommand extends Command {
17+
18+
/**
19+
* {@inheritdoc}
20+
*/
21+
protected function configure(): void {
22+
$this->addArgument('splits', InputArgument::OPTIONAL, "The number of splits", 1);
23+
$this->addArgument('index', InputArgument::OPTIONAL, "The index of the current split", 0);
24+
$this->addOption('tests-file', 't', InputOption::VALUE_REQUIRED, "The xml file listing all tests.", getcwd() . '/tests.xml');
25+
$this->addOption('results-file', 'f', InputOption::VALUE_REQUIRED, "The results cache file.", getcwd() . '/.phpunit.result.cache', );
26+
$this->addOption('bootstrap-file', 'b', InputOption::VALUE_OPTIONAL, "The tests bootstrap file.", getcwd() . '/tests/bootstrap.php');
27+
$this->addOption('prefix', 'p', InputOption::VALUE_OPTIONAL, "The prefix to remove from the file names.", getcwd() . '/');
28+
$this->addOption('json', 'j', InputOption::VALUE_NONE, "Output the result as json.");
29+
}
30+
31+
/**
32+
* {@inheritdoc}
33+
*/
34+
protected function execute(InputInterface $input, OutputInterface $output): int {
35+
$bootstrap = $input->getOption('bootstrap-file');
36+
if (\file_exists($bootstrap)) {
37+
include_once $bootstrap;
38+
}
39+
// @todo validation
40+
$splits = (int) $input->getArgument('splits');
41+
$index = (int) $input->getArgument('index');
42+
$testsFile = $input->getOption('tests-file');
43+
$resultsFile = $input->getOption('results-file');
44+
$prefix = $input->getOption('prefix');
45+
$json = $input->getOption('json');
46+
47+
$mapper = new TestMapper($testsFile, $resultsFile, $prefix);
48+
$map = $mapper->sortMap($mapper->getMap());
49+
50+
$split = $this->split($map, $splits, $index);
51+
if ($json) {
52+
$output->writeln(\json_encode($split));
53+
return Command::SUCCESS;
54+
}
55+
foreach ($split as $testPath => $test) {
56+
$output->writeln($testPath);
57+
}
58+
59+
return Command::SUCCESS;
60+
}
61+
62+
/**
63+
* Splits the map into the given number of splits.
64+
*
65+
* @param array<string,array<string,float>> $map
66+
*
67+
* @return array<string,array<string,float>>
68+
*/
69+
private function split(array $map, int $splits, int $index): array {
70+
$result = [];
71+
$keys = array_keys($map);
72+
$values = array_values($map);
73+
74+
for ($i = $index; $i < count($map); $i++) {
75+
if (($i - $index) % $splits === 0) {
76+
$result[$keys[$i]] = $values[$i];
77+
}
78+
}
79+
80+
return $result;
81+
}
82+
83+
}

src/TestMapper.php

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpUnitSplitter;
6+
7+
use PHPUnit\Runner\DefaultTestResultCache;
8+
use PHPUnit\Runner\TestResultCache;
9+
10+
/**
11+
* Generates a map of test methods with their file name and execution time.
12+
*/
13+
final class TestMapper {
14+
15+
private \SimpleXMLElement|false $testsXml;
16+
17+
private TestResultCache $resultCache;
18+
19+
private string $prefix;
20+
21+
/**
22+
* Constructs a new TestMapper.
23+
*/
24+
public function __construct(string $testListFilePath, string $testResultFilePath, string $prefix) {
25+
$this->testsXml = \simplexml_load_file($testListFilePath);
26+
$this->resultCache = new DefaultTestResultCache($testResultFilePath);
27+
$this->prefix = $prefix;
28+
}
29+
30+
/**
31+
* Returns a map of test methods with their file name and execution time.
32+
*
33+
* @return array<string,array<string,float>>
34+
* The map of test files and execution times.
35+
*/
36+
public function getMap(): array {
37+
$this->resultCache->load();
38+
$map = [];
39+
$classesXpath = $this->testsXml->xpath('//testCaseClass');
40+
foreach ($classesXpath as $class) {
41+
$className = (string) $class->attributes()['name'];
42+
try {
43+
$reflection = new \ReflectionClass($className);
44+
} catch (\ReflectionException $e) {
45+
// Couldn't find the class.
46+
continue;
47+
}
48+
$filename = $reflection->getFileName();
49+
if (\str_starts_with($filename, $this->prefix)) {
50+
$filename = \substr($filename, \strlen($this->prefix));
51+
}
52+
$map[$filename] = [
53+
'className' => $className,
54+
'time' => 0.0,
55+
];
56+
$testCases = $class->xpath('testCaseMethod');
57+
foreach ($testCases as $testCase) {
58+
$shortName = (string) $testCase->attributes()['name'];
59+
$fullName = $reflection->getName() . '::' . $shortName;
60+
$dataSet = $testCase->attributes()['dataSet'] ?? NULL;
61+
$cacheKey = $fullName;
62+
if ($dataSet !== NULL) {
63+
$cacheKey .= " with data set $dataSet";
64+
$shortName .= "@$dataSet";
65+
}
66+
$time = $this->resultCache->getTime($cacheKey);
67+
$map[$filename]['testCases'][] = [
68+
'testCase' => $shortName,
69+
'time' => $time,
70+
];
71+
$map[$filename]['time'] += $time;
72+
}
73+
}
74+
return $map;
75+
}
76+
77+
/**
78+
* Sorts the map by execution time.
79+
*
80+
* @param array<string,array<string,float>> $map
81+
* The map to sort.
82+
*
83+
* @return array<string,array<string,float>>
84+
* The sorted map.
85+
*/
86+
public function sortMap(array $map): array {
87+
uasort($map, function ($a, $b) {
88+
return $a['time'] <=> $b['time'];
89+
});
90+
return $map;
91+
}
92+
93+
}

tests/fixtures/.phpunit.result.cache

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
{"version":1,"defects":[],"times":{"PhpUnitSplitter\\Tests\\Fixtures\\FastTestsTest::testOne":0.011,"PhpUnitSplitter\\Tests\\Fixtures\\FastTestsTest::testTwo":0.02,"PhpUnitSplitter\\Tests\\Fixtures\\FastTestsTest::testThree":0.03,"PhpUnitSplitter\\Tests\\Fixtures\\FastTestsTest::testFour":0.04,"PhpUnitSplitter\\Tests\\Fixtures\\FastTestsTest::testFive":0.05,"PhpUnitSplitter\\Tests\\Fixtures\\ProviderTest::testProvider with data set \"one\"":0.111,"PhpUnitSplitter\\Tests\\Fixtures\\ProviderTest::testProvider with data set \"two\"":0.445,"PhpUnitSplitter\\Tests\\Fixtures\\ProviderTest::testProvider with data set \"three\"":0.222,"PhpUnitSplitter\\Tests\\Fixtures\\ProviderTest::testProvider with data set \"four\"":0.334,"PhpUnitSplitter\\Tests\\Fixtures\\ProviderTest::testProvider with data set \"five\"":0.667,"PhpUnitSplitter\\Tests\\Fixtures\\SlowTestsTest::testOne":0.1,"PhpUnitSplitter\\Tests\\Fixtures\\SlowTestsTest::testTwo":0.2,"PhpUnitSplitter\\Tests\\Fixtures\\SlowTestsTest::testThree":0.3,"PhpUnitSplitter\\Tests\\Fixtures\\SlowTestsTest::testFour":0.4,"PhpUnitSplitter\\Tests\\Fixtures\\SlowTestsTest::testFive":0.5}}

0 commit comments

Comments
 (0)