In T361190 and Quibble 1.9.0, we introduced parallel execution of PHPUnit tests to speed up the CI jobs. The existing implementation is purely Python/Quibble, and cannot directly be used by developers locally. With this patch, we re-implement the parallel test execution already implemented in CI as a composer task so that the parallel tests can be run locally. The `phpunit:parallel:extensions` command expects to be run after `phpunit:prepare-parallel:extensions`, and expects to find 8 test suites with the names `split_group_X` (for X in 0 through 7) in the PHPUnit configuration file. 8 here is currently a hard-coded number that corresponds to the number of parallel test executions we need to saturate the CPU of a 4-core developer machine, and experimentally leads to a good speed-up versus the serial execution. When this command runs, it forks to launch 8 parallel processes, each running one of the `split_group_X` suites. The parent process waits for the children to complete, buffers the output, collects the exit statuses, then dumps the buffered output and exits with a non-zero status if any of the child processes failed (i.e. if there were test failures). We introduce `phpunit:prepare-parallel:default` as a complement to `phpunit:prepare-parallel:extensions`, and the two commands `phpunit:parallel:database` and `phpunit:parallel:databaseless`. This creates four possible combinations - two different test suites, and two different test groups. This is a similar setup to that which we have in CI - the Database and non-Database tests are run in separate groups, and some jobs use the `extensions` suite while others just use the default suite. The `phpunit:parallel:...` commands will fail with a helpful message if no `split_group_`s are found in the active PHPUnit configuration. To help test whether the split test runs are really running all the tests in the suite, we generate and store the PHPUnit results cache file. Comparing the results cache files from linear versus parallel runs should tell us if all the tests have been executed. Bug: T365976 Change-Id: If106802f08edd5d4c841bb7970c69b88ab3bb39b
195 lines
6 KiB
PHP
195 lines
6 KiB
PHP
<?php
|
|
|
|
declare( strict_types = 1 );
|
|
|
|
namespace MediaWiki\Composer\PhpUnitSplitter;
|
|
|
|
/**
|
|
* @license GPL-2.0-or-later
|
|
*/
|
|
class PhpUnitXmlManager {
|
|
|
|
private string $rootDir;
|
|
|
|
private string $testsListFile;
|
|
|
|
/**
|
|
* The `SkippedTestCase` is generated dynamically by PHPUnit for tests
|
|
* that are marked as skipped. We don't need to find a matching filesystem
|
|
* file for these.
|
|
*
|
|
* The `ParserIntegrationTest` is a special case - it's a single test class
|
|
* that generates very many tests. To balance out the test suites, we exclude
|
|
* the class from the scan, and add it back in PhpUnitXml::addSpecialCaseTests
|
|
*/
|
|
private const EXPECTED_MISSING_CLASSES = [
|
|
"PHPUnit\\Framework\\SkippedTestCase",
|
|
"MediaWiki\\Extension\\Scribunto\\Tests\\Engines\\LuaCommon\\LuaEngineTestSkip",
|
|
"\\ParserIntegrationTest",
|
|
];
|
|
|
|
public function __construct( string $rootDir, string $testsListFile ) {
|
|
$this->rootDir = $rootDir;
|
|
$this->testsListFile = $testsListFile;
|
|
}
|
|
|
|
private function getPhpUnitXmlTarget(): string {
|
|
return $this->rootDir . DIRECTORY_SEPARATOR . PhpUnitXml::PHP_UNIT_XML_FILE;
|
|
}
|
|
|
|
private function getPhpUnitXmlDist(): string {
|
|
return $this->rootDir . DIRECTORY_SEPARATOR . "phpunit.xml.dist";
|
|
}
|
|
|
|
private function getTestsList(): string {
|
|
return $this->rootDir . DIRECTORY_SEPARATOR . $this->testsListFile;
|
|
}
|
|
|
|
private function loadPhpUnitXmlDist(): PhpUnitXml {
|
|
return $this->loadPhpUnitXml( $this->getPhpUnitXmlDist() );
|
|
}
|
|
|
|
private function loadPhpUnitXml( string $targetFile ): PhpUnitXml {
|
|
return new PhpUnitXml( $targetFile );
|
|
}
|
|
|
|
private function loadTestClasses(): array {
|
|
if ( !file_exists( $this->getTestsList() ) ) {
|
|
throw new TestListMissingException( $this->getTestsList() );
|
|
}
|
|
return ( new PhpUnitTestListProcessor( $this->getTestsList() ) )->getTestClasses();
|
|
}
|
|
|
|
private function scanForTestFiles(): array {
|
|
return ( new PhpUnitTestFileScanner( $this->rootDir ) )->scanForFiles();
|
|
}
|
|
|
|
private static function extractNamespaceFromFile( $filename ): array {
|
|
$contents = file_get_contents( $filename );
|
|
$matches = [];
|
|
if ( preg_match( '/^namespace\s+([^\s;]+)/m', $contents, $matches ) ) {
|
|
return explode( '\\', $matches[1] );
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @param TestDescriptor $testDescriptor
|
|
* @param array $phpFiles
|
|
* @return ?string
|
|
* @throws MissingNamespaceMatchForTestException
|
|
* @throws UnlocatedTestException
|
|
*/
|
|
private function resolveFileForTest( TestDescriptor $testDescriptor, array $phpFiles ): ?string {
|
|
$filename = $testDescriptor->getClassName() . ".php";
|
|
if ( !array_key_exists( $filename, $phpFiles ) ) {
|
|
if ( !in_array( $testDescriptor->getFullClassname(), self::EXPECTED_MISSING_CLASSES ) ) {
|
|
throw new UnlocatedTestException( $testDescriptor );
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
if ( count( $phpFiles[$filename] ) === 1 ) {
|
|
return $phpFiles[$filename][0];
|
|
}
|
|
$possibleNamespaces = [];
|
|
foreach ( $phpFiles[$filename] as $file ) {
|
|
$namespace = self::extractNamespaceFromFile( $file );
|
|
if ( $namespace === $testDescriptor->getNamespace() ) {
|
|
return $file;
|
|
}
|
|
$possibleNamespaces[] = $namespace;
|
|
}
|
|
throw new MissingNamespaceMatchForTestException( $testDescriptor );
|
|
}
|
|
|
|
private function buildSuites( array $testClasses, int $groups ): array {
|
|
return ( new TestSuiteBuilder() )->buildSuites( $testClasses, $groups );
|
|
}
|
|
|
|
public function isPhpUnitXmlPrepared(): bool {
|
|
return PhpUnitXml::isPhpUnitXmlPrepared( $this->rootDir . DIRECTORY_SEPARATOR . "phpunit.xml" );
|
|
}
|
|
|
|
/**
|
|
* @return void
|
|
* @throws MissingNamespaceMatchForTestException
|
|
* @throws TestListMissingException
|
|
* @throws UnlocatedTestException
|
|
* @throws SuiteGenerationException
|
|
*/
|
|
public function createPhpUnitXml( int $groups ) {
|
|
$unitFile = $this->loadPhpUnitXmlDist();
|
|
$testFiles = $this->scanForTestFiles();
|
|
$testClasses = $this->loadTestClasses();
|
|
$seenFiles = [];
|
|
foreach ( $testClasses as $testDescriptor ) {
|
|
$file = $this->resolveFileForTest( $testDescriptor, $testFiles );
|
|
if ( is_string( $file ) && !array_key_exists( $file, $seenFiles ) ) {
|
|
$testDescriptor->setFilename( $file );
|
|
$seenFiles[$file] = 1;
|
|
}
|
|
}
|
|
$suites = $this->buildSuites( $testClasses, $groups - 1 );
|
|
$unitFile->addSplitGroups( $suites );
|
|
$unitFile->addSpecialCaseTests( $groups );
|
|
$unitFile->saveToDisk( $this->getPhpUnitXmlTarget() );
|
|
}
|
|
|
|
public static function listTestsNotice() {
|
|
print( PHP_EOL );
|
|
print( 'Running `phpunit --list-tests-xml` to get a list of expected tests ... ' . PHP_EOL );
|
|
print( PHP_EOL );
|
|
}
|
|
|
|
/**
|
|
* @throws TestListMissingException
|
|
* @throws UnlocatedTestException
|
|
* @throws MissingNamespaceMatchForTestException
|
|
* @throws SuiteGenerationException
|
|
*/
|
|
public static function splitTestsList( string $testListFile ) {
|
|
/**
|
|
* We split into 8 groups here, because experimentally that generates 100% CPU load
|
|
* on developer machines and results in groups that are similar in size to the
|
|
* Parser tests (which we have to run in a group on their own - see T345481)
|
|
*/
|
|
( new PhpUnitXmlManager( getcwd(), $testListFile ) )->createPhpUnitXml( 8 );
|
|
print( PHP_EOL . 'Created modified `phpunit.xml` with test suite groups' . PHP_EOL );
|
|
}
|
|
|
|
/**
|
|
* @throws TestListMissingException
|
|
* @throws UnlocatedTestException
|
|
* @throws MissingNamespaceMatchForTestException
|
|
* @throws SuiteGenerationException
|
|
*/
|
|
public static function splitTestsListExtensions() {
|
|
self::splitTestsList( 'tests-list-extensions.xml' );
|
|
}
|
|
|
|
/**
|
|
* @throws TestListMissingException
|
|
* @throws UnlocatedTestException
|
|
* @throws MissingNamespaceMatchForTestException
|
|
* @throws SuiteGenerationException
|
|
*/
|
|
public static function splitTestsListDefault() {
|
|
self::splitTestsList( 'tests-list-default.xml' );
|
|
}
|
|
|
|
/**
|
|
* @throws TestListMissingException
|
|
* @throws UnlocatedTestException
|
|
* @throws MissingNamespaceMatchForTestException
|
|
* @throws SuiteGenerationException
|
|
*/
|
|
public static function splitTestsCustom() {
|
|
if ( $_SERVER["argc"] < 3 ) {
|
|
print( 'Specify a filename to split' . PHP_EOL );
|
|
exit( 1 );
|
|
}
|
|
$filename = $_SERVER["argv"][2];
|
|
self::splitTestsList( $filename );
|
|
}
|
|
}
|