diff --git a/phpunit.xml b/phpunit.xml index 10017aaae9d..035f504a732 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -35,6 +35,7 @@ tests/end-to-end/logging/testdox tests/end-to-end/metadata tests/end-to-end/migration + tests/end-to-end/parallel tests/end-to-end/phpt tests/end-to-end/regression tests/end-to-end/repeat @@ -48,6 +49,11 @@ tests/end-to-end/groups-from-configuration/_files tests/end-to-end/logging/_files tests/end-to-end/migration/_files + tests/end-to-end/parallel/phpt/_files + tests/end-to-end/parallel/phpt-coverage/_files + tests/end-to-end/parallel/phpt-conflicts/_files + tests/end-to-end/parallel/phpt-with-classes/_files + tests/end-to-end/parallel/repeat-retry/_files tests/end-to-end/repeat/_files tests/end-to-end/retry/_files tests/end-to-end/self-direct-indirect/_files diff --git a/src/Event/CollectingEmitter.php b/src/Event/CollectingEmitter.php new file mode 100644 index 00000000000..aa9d55b66a6 --- /dev/null +++ b/src/Event/CollectingEmitter.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Event; + +/** + * An emitter that collects the events emitted through it into an event + * collection that can later be flushed and replayed, without disturbing the + * global event facade. + * + * Several of these can be in use at the same time in one process, which is what + * lets the parallel test runner run several PHPT tests concurrently in the main + * process, each collecting its own events for deterministic, suite-ordered + * replay through Facade::forward(). + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final readonly class CollectingEmitter +{ + private Emitter $emitter; + private CollectingDispatcher $dispatcher; + + public function __construct(Emitter $emitter, CollectingDispatcher $dispatcher) + { + $this->emitter = $emitter; + $this->dispatcher = $dispatcher; + } + + public function emitter(): Emitter + { + return $this->emitter; + } + + public function flush(): EventCollection + { + return $this->dispatcher->flush(); + } +} diff --git a/src/Event/Dispatcher/CollectingDispatcher.php b/src/Event/Dispatcher/CollectingDispatcher.php index e3e9462ee6a..49dc016b5d3 100644 --- a/src/Event/Dispatcher/CollectingDispatcher.php +++ b/src/Event/Dispatcher/CollectingDispatcher.php @@ -9,6 +9,7 @@ */ namespace PHPUnit\Event; +use function assert; use PHPUnit\Runner\DeprecationCollector\Facade as DeprecationCollector; use PHPUnit\Runner\DeprecationCollector\TestTriggeredDeprecationSubscriber; @@ -21,6 +22,7 @@ final class CollectingDispatcher implements Dispatcher { private EventCollection $events; private DirectDispatcher $isolatedDirectDispatcher; + private ?EventCollection $collectedEvents = null; public function __construct(DirectDispatcher $directDispatcher) { @@ -32,6 +34,12 @@ public function __construct(DirectDispatcher $directDispatcher) public function dispatch(Event $event): void { + if ($this->collectedEvents !== null) { + $this->collectedEvents->add($event); + + return; + } + $this->events->add($event); try { @@ -41,6 +49,31 @@ public function dispatch(Event $event): void } } + /** + * Open a collection window: until stopCollectingEvents() is called, events + * are diverted into a separate collection instead of being recorded and + * dispatched. This mirrors the collection window of the DeferringDispatcher + * so that a RetryTestSuite can suppress the events of a failed attempt when + * it runs in a process whose event facade was initialized for isolation. + */ + public function startCollectingEvents(): void + { + assert($this->collectedEvents === null); + + $this->collectedEvents = new EventCollection; + } + + public function stopCollectingEvents(): EventCollection + { + assert($this->collectedEvents !== null); + + $events = $this->collectedEvents; + + $this->collectedEvents = null; + + return $events; + } + public function flush(): EventCollection { $events = $this->events; diff --git a/src/Event/Facade.php b/src/Event/Facade.php index 349efbb1f3d..a821a7cd9d1 100644 --- a/src/Event/Facade.php +++ b/src/Event/Facade.php @@ -24,9 +24,10 @@ final class Facade { private static ?self $instance = null; private Emitter $emitter; - private ?TypeMap $typeMap = null; - private ?DeferringDispatcher $deferringDispatcher = null; - private bool $sealed = false; + private ?TypeMap $typeMap = null; + private ?DeferringDispatcher $deferringDispatcher = null; + private ?CollectingDispatcher $isolationDispatcher = null; + private bool $sealed = false; public static function instance(): self { @@ -108,25 +109,70 @@ public function initForIsolation(HRTime $offset): CollectingDispatcher $this->sealed = true; + $this->isolationDispatcher = $dispatcher; + return $dispatcher; } + /** + * Mint an emitter that collects its events into an independent collection, + * without sealing this facade or replacing its emitter. Unlike + * initForIsolation(), which can only establish one isolated emitter for the + * whole process, any number of these can be in use at once; the parallel + * test runner uses one per concurrently running PHPT test. + */ + public function collectingEmitter(): CollectingEmitter + { + $dispatcher = new CollectingDispatcher( + new DirectDispatcher($this->typeMap()), + ); + + return new CollectingEmitter( + new DispatchingEmitter( + $dispatcher, + $this->createTelemetrySystem(), + ), + $dispatcher, + ); + } + public function forward(EventCollection $events): void { - $dispatcher = $this->deferredDispatcher(); + if ($this->isolationDispatcher !== null) { + $dispatcher = $this->isolationDispatcher; + } else { + $dispatcher = $this->deferredDispatcher(); + } foreach ($events as $event) { $dispatcher->dispatch($event); } } + /** + * In a process whose event facade was initialized for isolation — a + * parallel test runner worker, for example — the collection window is + * opened on the isolation dispatcher, because that is the dispatcher the + * emitter dispatches to there; the deferring dispatcher is unused in such + * a process. + */ public function startCollectingEvents(): void { + if ($this->isolationDispatcher !== null) { + $this->isolationDispatcher->startCollectingEvents(); + + return; + } + $this->deferredDispatcher()->startCollectingEvents(); } public function stopCollectingEvents(): EventCollection { + if ($this->isolationDispatcher !== null) { + return $this->isolationDispatcher->stopCollectingEvents(); + } + return $this->deferredDispatcher()->stopCollectingEvents(); } diff --git a/src/Framework/Attributes/DoNotRunInParallel.php b/src/Framework/Attributes/DoNotRunInParallel.php new file mode 100644 index 00000000000..10bef641389 --- /dev/null +++ b/src/Framework/Attributes/DoNotRunInParallel.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\Attributes; + +use Attribute; + +/** + * @immutable + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + */ +#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)] +final readonly class DoNotRunInParallel +{ +} diff --git a/src/Framework/IterativeTestSuite.php b/src/Framework/IterativeTestSuite.php index 089c2fb9672..fe705b1fb8d 100644 --- a/src/Framework/IterativeTestSuite.php +++ b/src/Framework/IterativeTestSuite.php @@ -122,6 +122,12 @@ final protected function dependencies(): array */ abstract protected function execute(array $tests, Event\Emitter $emitter): void; + /** + * Synthesize a test attempt event from the events an attempt collected, so + * that a failed or errored attempt that is going to be retried is reported + * as an attempt rather than as the test's final outcome. Returns whether + * such an event could be determined from the collected events. + */ final protected function emitAttemptEvent(EventCollection $events, Event\Emitter $emitter): bool { $duration = $this->durationOf($events); diff --git a/src/Framework/RepeatTestSuite.php b/src/Framework/RepeatTestSuite.php index b25a9ea34e3..8fe431457ac 100644 --- a/src/Framework/RepeatTestSuite.php +++ b/src/Framework/RepeatTestSuite.php @@ -46,6 +46,14 @@ public static function fromTests(string $name, array $tests, int $failureThresho return $suite; } + /** + * @return positive-int + */ + public function failureThreshold(): int + { + return $this->failureThreshold; + } + /** * @param list $tests */ diff --git a/src/Framework/TestRunner/ChildProcessResultEnvelope.php b/src/Framework/TestRunner/ChildProcessResultEnvelope.php new file mode 100644 index 00000000000..3a80951068a --- /dev/null +++ b/src/Framework/TestRunner/ChildProcessResultEnvelope.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Framework\TestRunner; + +use function hash_equals; +use function strlen; +use function substr; +use PHPUnit\Runner\CodeCoverage; +use SebastianBergmann\CodeCoverage\CodeCoverage as CodeCoverageData; + +/** + * The mechanics shared by the two consumers of the serialized result envelope + * that a process-isolated child or a parallel worker writes back to the parent: + * the ChildProcessResultProcessor and the parallel ResultAggregator. + * + * Only the parts that are identical between the two live here. The shape each + * one requires of the decoded envelope, and the way each reports a malformed + * result, differ and stay with each consumer. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class ChildProcessResultEnvelope +{ + /** + * Verify the nonce that prefixes the serialized result and strip it, + * returning the bare payload. + * + * A null nonce or an empty payload is passed through unverified — the caller + * then fails on the empty or unexpected payload when it tries to decode it. + * Null is returned only when a nonce was expected but the prefix does not + * match it, which means the result was written by an unexpected process or + * was tampered with. + * + * @param ?non-empty-string $nonce + */ + public static function verifyAndStripNonce(string $serialized, ?string $nonce): ?string + { + if ($nonce === null || $serialized === '') { + return $serialized; + } + + $length = strlen($nonce); + + if (strlen($serialized) < $length || + !hash_equals($nonce, substr($serialized, 0, $length))) { + return null; + } + + return substr($serialized, $length); + } + + /** + * Merge the code coverage carried by a decoded result envelope into the + * parent's coverage, when coverage collection is active and the envelope + * actually carries it. + */ + public static function mergeCodeCoverage(object $result, CodeCoverage $codeCoverage): void + { + if (!$codeCoverage->isActive()) { + return; + } + + // @codeCoverageIgnoreStart + if (!isset($result->codeCoverage) || !$result->codeCoverage instanceof CodeCoverageData) { + return; + } + + CodeCoverage::instance()->codeCoverage()->merge($result->codeCoverage); + // @codeCoverageIgnoreEnd + } +} diff --git a/src/Framework/TestRunner/ChildProcessResultProcessor.php b/src/Framework/TestRunner/ChildProcessResultProcessor.php index 9e3418b8efb..4bad22a2dd1 100644 --- a/src/Framework/TestRunner/ChildProcessResultProcessor.php +++ b/src/Framework/TestRunner/ChildProcessResultProcessor.php @@ -10,11 +10,8 @@ namespace PHPUnit\Framework\TestRunner; use function assert; -use function hash_equals; use function is_int; use function property_exists; -use function strlen; -use function substr; use function trim; use function unserialize; use PHPUnit\Event\Code\TestMethodBuilder; @@ -81,38 +78,33 @@ public function process(Test $test, string $serializedProcessResult, string $std return; } - if ($processResultNonce !== null && $serializedProcessResult !== '') { - $nonceLength = strlen($processResultNonce); + $verifiedProcessResult = ChildProcessResultEnvelope::verifyAndStripNonce($serializedProcessResult, $processResultNonce); - if (strlen($serializedProcessResult) < $nonceLength || - !hash_equals($processResultNonce, substr($serializedProcessResult, 0, $nonceLength))) { - $this->emitter->childProcessErrored(); - - $exception = new AssertionFailedError( - 'Test was run in child process and the result file was tampered with or written by an unexpected process', - ); + if ($verifiedProcessResult === null) { + $this->emitter->childProcessErrored(); - assert($test instanceof TestCase); + $exception = new AssertionFailedError( + 'Test was run in child process and the result file was tampered with or written by an unexpected process', + ); - $test->setStatus(TestStatus::error($exception->getMessage())); + assert($test instanceof TestCase); - $this->emitter->testErrored( - TestMethodBuilder::fromTestCase($test), - ThrowableBuilder::from($exception), - ); + $test->setStatus(TestStatus::error($exception->getMessage())); - $this->emitter->testFinished( - TestMethodBuilder::fromTestCase($test), - 0, - ); + $this->emitter->testErrored( + TestMethodBuilder::fromTestCase($test), + ThrowableBuilder::from($exception), + ); - return; - } + $this->emitter->testFinished( + TestMethodBuilder::fromTestCase($test), + 0, + ); - $serializedProcessResult = substr($serializedProcessResult, $nonceLength); + return; } - $childResult = @unserialize($serializedProcessResult); + $childResult = @unserialize($verifiedProcessResult); if (!$childResult instanceof stdClass || !property_exists($childResult, 'events') || @@ -155,18 +147,6 @@ public function process(Test $test, string $serializedProcessResult, string $std $test->setStatus($childResult->status); $test->addToAssertionCount($childResult->numAssertions); - if (!$this->codeCoverage->isActive()) { - return; - } - - // @codeCoverageIgnoreStart - if (!isset($childResult->codeCoverage) || !$childResult->codeCoverage instanceof \SebastianBergmann\CodeCoverage\CodeCoverage) { - return; - } - - CodeCoverage::instance()->codeCoverage()->merge( - $childResult->codeCoverage, - ); - // @codeCoverageIgnoreEnd + ChildProcessResultEnvelope::mergeCodeCoverage($childResult, $this->codeCoverage); } } diff --git a/src/Metadata/DoNotRunInParallel.php b/src/Metadata/DoNotRunInParallel.php new file mode 100644 index 00000000000..038a7a44ee1 --- /dev/null +++ b/src/Metadata/DoNotRunInParallel.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Metadata; + +/** + * @immutable + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + */ +final readonly class DoNotRunInParallel extends Metadata +{ + public function isDoNotRunInParallel(): true + { + return true; + } +} diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 5249ab1f78f..d79358d50f1 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -175,6 +175,16 @@ public static function disableReturnValueGenerationForTestDoubles(): DisableRetu return new DisableReturnValueGenerationForTestDoubles(Level::CLASS_LEVEL); } + public static function doNotRunInParallelOnClass(): DoNotRunInParallel + { + return new DoNotRunInParallel(Level::CLASS_LEVEL); + } + + public static function doNotRunInParallelOnMethod(): DoNotRunInParallel + { + return new DoNotRunInParallel(Level::METHOD_LEVEL); + } + public static function doesNotPerformAssertionsOnClass(): DoesNotPerformAssertions { return new DoesNotPerformAssertions(Level::CLASS_LEVEL); @@ -789,6 +799,14 @@ public function isDoesNotPerformAssertions(): bool return false; } + /** + * @phpstan-assert-if-true DoNotRunInParallel $this + */ + public function isDoNotRunInParallel(): bool + { + return false; + } + /** * @phpstan-assert-if-true ExcludeGlobalVariableFromBackup $this */ diff --git a/src/Metadata/MetadataCollection.php b/src/Metadata/MetadataCollection.php index 4a6c4311d72..055f6bf00cf 100644 --- a/src/Metadata/MetadataCollection.php +++ b/src/Metadata/MetadataCollection.php @@ -348,6 +348,16 @@ public function isDoesNotPerformAssertions(): self ); } + public function isDoNotRunInParallel(): self + { + return new self( + ...array_filter( + $this->metadata, + static fn (Metadata $metadata): bool => $metadata->isDoNotRunInParallel(), + ), + ); + } + public function isGroup(): self { return new self( diff --git a/src/Metadata/Parser/AttributeParser.php b/src/Metadata/Parser/AttributeParser.php index d0409eaf167..5425b369b42 100644 --- a/src/Metadata/Parser/AttributeParser.php +++ b/src/Metadata/Parser/AttributeParser.php @@ -51,6 +51,7 @@ use PHPUnit\Framework\Attributes\DependsUsingShallowClone; use PHPUnit\Framework\Attributes\DisableReturnValueGenerationForTestDoubles; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; +use PHPUnit\Framework\Attributes\DoNotRunInParallel; use PHPUnit\Framework\Attributes\ExcludeGlobalVariableFromBackup; use PHPUnit\Framework\Attributes\ExcludeStaticPropertyFromBackup; use PHPUnit\Framework\Attributes\Group; @@ -244,6 +245,11 @@ public function forClass(string $className): MetadataCollection break; + case DoNotRunInParallel::class: + $result[] = Metadata::doNotRunInParallelOnClass(); + + break; + case ExcludeGlobalVariableFromBackup::class: assert($attributeInstance instanceof ExcludeGlobalVariableFromBackup); @@ -724,6 +730,11 @@ public function forMethod(string $className, string $methodName): MetadataCollec break; + case DoNotRunInParallel::class: + $result[] = Metadata::doNotRunInParallelOnMethod(); + + break; + case ExcludeGlobalVariableFromBackup::class: assert($attributeInstance instanceof ExcludeGlobalVariableFromBackup); diff --git a/src/Runner/Parallel/CompletedWorkUnit.php b/src/Runner/Parallel/CompletedWorkUnit.php new file mode 100644 index 00000000000..8d3230d0564 --- /dev/null +++ b/src/Runner/Parallel/CompletedWorkUnit.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +/** + * The outcome of a WorkUnit that a worker has finished: the serialized result + * envelope written by the worker, together with the unit it belongs to and the + * nonce that authenticates the envelope. + * + * A unit whose worker died before reporting completion is marked as crashed; + * the serialized envelope is then empty. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final readonly class CompletedWorkUnit +{ + private WorkUnit $unit; + private string $serializedResult; + + /** + * @var ?non-empty-string + */ + private ?string $nonce; + private bool $crashed; + + /** + * A human-readable explanation of why a crashed unit did not run, used in + * place of the generic "ended unexpectedly" message when a more specific + * reason is known (for example, that the unit's test data could not be + * serialized for transport to a worker). + */ + private ?string $message; + + /** + * @param ?non-empty-string $nonce + */ + public function __construct(WorkUnit $unit, string $serializedResult, ?string $nonce, bool $crashed, ?string $message = null) + { + $this->unit = $unit; + $this->serializedResult = $serializedResult; + $this->nonce = $nonce; + $this->crashed = $crashed; + $this->message = $message; + } + + public function unit(): WorkUnit + { + return $this->unit; + } + + public function serializedResult(): string + { + return $this->serializedResult; + } + + /** + * @return ?non-empty-string + */ + public function nonce(): ?string + { + return $this->nonce; + } + + public function crashed(): bool + { + return $this->crashed; + } + + public function message(): ?string + { + return $this->message; + } +} diff --git a/src/Runner/Parallel/Exception/WorkerException.php b/src/Runner/Parallel/Exception/WorkerException.php new file mode 100644 index 00000000000..2780d7089ea --- /dev/null +++ b/src/Runner/Parallel/Exception/WorkerException.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Exception; +use RuntimeException; + +/** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class WorkerException extends RuntimeException implements Exception +{ +} diff --git a/src/Runner/Parallel/PersistentWorker.php b/src/Runner/Parallel/PersistentWorker.php new file mode 100644 index 00000000000..a7e722f66e7 --- /dev/null +++ b/src/Runner/Parallel/PersistentWorker.php @@ -0,0 +1,546 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function assert; +use function base64_encode; +use function bin2hex; +use function count; +use function defined; +use function file_get_contents; +use function get_include_path; +use function hrtime; +use function is_file; +use function json_encode; +use function random_bytes; +use function serialize; +use function sprintf; +use function sys_get_temp_dir; +use function tempnam; +use function unlink; +use function var_export; +use PHPUnit\Event\Facade as EventFacade; +use PHPUnit\Framework\RepeatTestSuite; +use PHPUnit\Framework\RetryTestSuite; +use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; +use PHPUnit\TextUI\Configuration\SourceMapper; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; +use PHPUnit\Util\PHP\RunningJob; +use ReflectionClass; +use SebastianBergmann\Template\Template; +use Throwable; + +/** + * A worker process that boots PHPUnit once and then executes an arbitrary + * number of tests, each in response to a command received on its control + * channel (its standard input). A unit of work is a whole test class; the + * worker reconstructs it from the dispatched command, runs it, and reports the + * result back through the filesystem (see dispatch() and poll()). + * + * Unlike the SeparateProcessTestRunner, which spawns one process per test for + * isolation, a PersistentWorker amortizes the cost of bootstrapping PHPUnit + * across all of the units it runs. The tests executed by a single worker share + * one process and therefore do not get the per-test global-state isolation that + * process isolation provides; this is the trade-off that makes the worker + * suitable as a building block for parallel test execution. + * + * The result of each unit is transported back to the parent process as the same + * kind of serialized envelope that process isolation uses, which the + * ResultAggregator decodes and replays into the parent's event subsystem. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class PersistentWorker +{ + private readonly JobRunner $jobRunner; + private ?RunningJob $job = null; + + /** + * @var list + */ + private array $temporaryFiles = []; + + /** + * The unit currently being executed by this worker, together with the + * bookkeeping needed to harvest its result once the worker reports that it + * has finished. These are set by dispatch() and cleared by poll() (or by a + * detected crash); a null currentUnit means the worker is idle. + * + * Completion is signalled through the filesystem rather than the worker's + * standard output: once the worker has written the result file it creates a + * sibling "done" file, whose appearance the parent detects by polling. This + * avoids stream_select() and non-blocking reads on the worker's output pipe, + * neither of which works on Windows. + */ + private ?WorkUnit $currentUnit = null; + + /** + * @var ?non-empty-string + */ + private ?string $currentNonce = null; + private ?string $currentResultFile = null; + private ?string $currentDoneFile = null; + + /** + * @var non-negative-int + */ + private readonly int $id; + + /** + * @var non-empty-string + */ + private readonly string $token; + + /** + * @param non-negative-int $id + */ + public function __construct(JobRunner $jobRunner, int $id = 0) + { + $this->jobRunner = $jobRunner; + $this->id = $id; + $this->token = $id . '_' . bin2hex(random_bytes(16)); + } + + /** + * @throws WorkerException + */ + public function start(): void + { + // The worker's identity is exposed to the tests it runs so that test + // fixtures can partition shared resources (a database, a port, a + // temporary directory, ...) per worker and thereby avoid colliding with + // the tests running concurrently in the other workers. + // + // Two identifiers are provided: PHPUNIT_WORKER_ID is the small, stable + // ordinal (0, 1, 2, ...) that is ideal for indexing a fixed set of + // pre-provisioned resources, while PHPUNIT_WORKER_TOKEN adds a value + // that is unique across workers and across runs, for resources that must + // not collide with those left behind by a previous run. + $environmentVariables = [ + 'PHPUNIT_WORKER_ID' => (string) $this->id, + 'PHPUNIT_WORKER_TOKEN' => $this->token, + ]; + + $this->job = $this->jobRunner->start( + new Job($this->buildWorkerCode(), [], $environmentVariables), + ); + } + + /** + * Send a unit of work to the worker without waiting for it to finish. + * + * The command is written to the worker's standard input and dispatch() + * returns immediately; the caller is expected to poll this worker with + * poll() until it reports completion, so that a single thread of control can + * keep several workers busy at the same time. + * + * @throws WorkerException + */ + public function dispatch(WorkUnit $unit): void + { + assert($this->job !== null); + assert($this->currentUnit === null); + + $offset = hrtime(); + $nonce = bin2hex(random_bytes(16)); + $resultFile = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($resultFile === false) { + // @codeCoverageIgnoreStart + throw new WorkerException('Unable to create temporary file for the worker result'); + // @codeCoverageIgnoreEnd + } + + assert($unit instanceof TestClassWorkUnit); + + $doneFile = $resultFile . '.done'; + + $command = $this->testClassCommand($unit, $offset, $resultFile, $doneFile, $nonce); + + $encodedCommand = json_encode($command); + + assert($encodedCommand !== false); + + $this->currentUnit = $unit; + $this->currentNonce = $nonce; + $this->currentResultFile = $resultFile; + $this->currentDoneFile = $doneFile; + + $this->job->write($encodedCommand . "\n"); + } + + /** + * Whether the worker is currently executing a dispatched unit. + */ + public function isBusy(): bool + { + return $this->currentUnit !== null; + } + + /** + * Whether the worker process is still usable. A worker that died while + * running a unit is no longer usable and must not be dispatched to again. + */ + public function isAlive(): bool + { + return $this->job !== null; + } + + /** + * Check, without blocking, whether the dispatched unit has finished and, if + * so, harvest its result; if the worker has died without finishing it, + * report the unit as crashed instead. + * + * Returns null while the unit is still running. The caller is expected to + * call this repeatedly, sleeping briefly between rounds, until it returns a + * completed unit. + */ + public function poll(): ?CompletedWorkUnit + { + assert($this->currentUnit !== null); + assert($this->currentDoneFile !== null); + + if (is_file($this->currentDoneFile)) { + return $this->finished(); + } + + if ($this->job === null || !$this->job->isRunning()) { + return $this->crashed(); + } + + return null; + } + + public function stop(): void + { + if ($this->job !== null) { + $encodedCommand = json_encode(['command' => 'stop']); + + assert($encodedCommand !== false); + + $this->job->write($encodedCommand . "\n"); + $this->job->closeStdin(); + + $result = $this->job->wait(); + + EventFacade::emitter()->childProcessFinished($result->stdout(), $result->stderr()); + + $this->job = null; + } + + foreach ($this->temporaryFiles as $temporaryFile) { + @unlink($temporaryFile); + } + + $this->temporaryFiles = []; + } + + /** + * @param array{0: int, 1: int} $offset + * @param non-empty-string $resultFile + * @param non-empty-string $doneFile + * @param non-empty-string $nonce + * + * @throws WorkerException + * + * @return array + */ + private function testClassCommand(TestClassWorkUnit $unit, array $offset, string $resultFile, string $doneFile, string $nonce): array + { + $class = new ReflectionClass($unit->className()); + $file = $class->getFileName(); + + assert($file !== false); + + $tests = []; + + foreach ($unit->tests() as $test) { + if ($test instanceof RetryTestSuite) { + $aggregated = $test->tests(); + + assert(count($aggregated) === 1 && $aggregated[0] instanceof TestCase); + + $tests[] = [ + 'type' => 'retry', + 'name' => $test->name(), + 'maxAttempts' => $test->maxAttempts(), + 'test' => $this->testDescriptor($aggregated[0], $unit->className(), $resultFile), + ]; + + continue; + } + + if ($test instanceof RepeatTestSuite) { + $repetitions = []; + + foreach ($test->tests() as $repetition) { + assert($repetition instanceof TestCase); + + $repetitions[] = $this->testDescriptor($repetition, $unit->className(), $resultFile); + } + + $tests[] = [ + 'type' => 'repeat', + 'name' => $test->name(), + 'failureThreshold' => $test->failureThreshold(), + 'tests' => $repetitions, + ]; + + continue; + } + + assert($test instanceof TestCase); + + $tests[] = $this->testDescriptor($test, $unit->className(), $resultFile); + } + + return [ + 'command' => 'runUnit', + 'file' => $file, + 'className' => $unit->className(), + 'tests' => $tests, + 'offsetSeconds' => $offset[0], + 'offsetNanoseconds' => $offset[1], + 'resultFile' => $resultFile, + 'doneFile' => $doneFile, + 'nonce' => $nonce, + ]; + } + + /** + * The transportable description of a single test case, from which the + * worker reconstructs it. + * + * @param class-string $className + * @param non-empty-string $resultFile + * + * @throws WorkerException + * + * @return array + */ + private function testDescriptor(TestCase $test, string $className, string $resultFile): array + { + try { + $data = base64_encode(serialize($test->providedData())); + $dependencyInput = base64_encode(serialize($test->dependencyInput())); + } catch (Throwable $t) { + @unlink($resultFile); + + throw new WorkerException( + sprintf( + 'The tests of class %s cannot be run in parallel because their data cannot be serialized: %s', + $className, + $t->getMessage(), + ), + ); + } + + return [ + 'type' => 'test', + 'methodName' => $test->name(), + 'data' => $data, + 'dataName' => $test->dataName(), + 'dependencyInput' => $dependencyInput, + 'repetition' => $test->repetition(), + 'totalRepetitions' => $test->totalRepetitions(), + 'attempt' => $test->attempt(), + 'maxAttempts' => $test->maxAttempts(), + ]; + } + + /** + * Harvest the result of the unit the worker has just reported as finished. + */ + private function finished(): CompletedWorkUnit + { + assert($this->currentUnit !== null); + assert($this->currentResultFile !== null); + assert($this->currentDoneFile !== null); + + $serializedResult = file_get_contents($this->currentResultFile); + + @unlink($this->currentResultFile); + @unlink($this->currentDoneFile); + + if ($serializedResult === false) { + // @codeCoverageIgnoreStart + $serializedResult = ''; + // @codeCoverageIgnoreEnd + } + + $completed = new CompletedWorkUnit( + $this->currentUnit, + $serializedResult, + $this->currentNonce, + false, + ); + + $this->clearCurrentUnit(); + + return $completed; + } + + /** + * Record that the worker died while running the dispatched unit and reap + * the dead process so that it is not used again. + */ + private function crashed(): CompletedWorkUnit + { + assert($this->currentUnit !== null); + + if ($this->currentResultFile !== null) { + @unlink($this->currentResultFile); + } + + if ($this->currentDoneFile !== null) { + @unlink($this->currentDoneFile); + } + + $completed = new CompletedWorkUnit($this->currentUnit, '', null, true); + + if ($this->job !== null) { + $this->job->wait(); + + $this->job = null; + } + + $this->clearCurrentUnit(); + + return $completed; + } + + private function clearCurrentUnit(): void + { + $this->currentUnit = null; + $this->currentNonce = null; + $this->currentResultFile = null; + $this->currentDoneFile = null; + } + + /** + * @throws WorkerException + * + * @return non-empty-string + */ + private function buildWorkerCode(): string + { + $configuration = ConfigurationRegistry::get(); + + $bootstrap = ''; + + if ($configuration->hasBootstrap()) { + $bootstrap = $configuration->bootstrap(); + } + + if (defined('PHPUNIT_COMPOSER_INSTALL')) { + $composerAutoload = var_export(PHPUNIT_COMPOSER_INSTALL, true); + } else { + // @codeCoverageIgnoreStart + $composerAutoload = '\'\''; + // @codeCoverageIgnoreEnd + } + + if (defined('__PHPUNIT_PHAR__')) { + // @codeCoverageIgnoreStart + $phar = var_export(__PHPUNIT_PHAR__, true); + // @codeCoverageIgnoreEnd + } else { + $phar = '\'\''; + } + + if (CodeCoverage::instance()->isActive()) { + $coverage = 'true'; + } else { + // @codeCoverageIgnoreStart + $coverage = 'false'; + // @codeCoverageIgnoreEnd + } + + $includePath = var_export(get_include_path(), true); + $includePath = "'." . $includePath . ".'"; + + $template = new Template(__DIR__ . '/templates/worker.tpl'); + + $template->setVar( + [ + 'composerAutoload' => $composerAutoload, + 'phar' => $phar, + 'collectCodeCoverageInformation' => $coverage, + 'iniSettings' => '', + 'include_path' => $includePath, + 'serializedConfiguration' => $this->saveConfigurationForChildProcess(), + 'sourceMapFile' => $this->sourceMapFileForChildProcess(), + 'bootstrap' => $bootstrap, + ], + ); + + $code = $template->render(); + + assert($code !== ''); + + return $code; + } + + /** + * @throws WorkerException + */ + private function saveConfigurationForChildProcess(): string + { + $path = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($path === false) { + // @codeCoverageIgnoreStart + throw new WorkerException('Unable to create temporary file for the worker configuration'); + // @codeCoverageIgnoreEnd + } + + if (!ConfigurationRegistry::saveTo($path)) { + // @codeCoverageIgnoreStart + throw new WorkerException('Unable to write the worker configuration to a temporary file'); + // @codeCoverageIgnoreEnd + } + + $this->temporaryFiles[] = $path; + + return $path; + } + + private function sourceMapFileForChildProcess(): string + { + if (!ConfigurationRegistry::get()->source()->notEmpty()) { + // @codeCoverageIgnoreStart + return ''; + // @codeCoverageIgnoreEnd + } + + $path = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($path === false) { + // @codeCoverageIgnoreStart + return ''; + // @codeCoverageIgnoreEnd + } + + if (!SourceMapper::saveTo($path, ConfigurationRegistry::get()->source())) { + // @codeCoverageIgnoreStart + return ''; + // @codeCoverageIgnoreEnd + } + + $this->temporaryFiles[] = $path; + + return $path; + } +} diff --git a/src/Runner/Parallel/PhptRunner.php b/src/Runner/Parallel/PhptRunner.php new file mode 100644 index 00000000000..b3019de0377 --- /dev/null +++ b/src/Runner/Parallel/PhptRunner.php @@ -0,0 +1,267 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function array_merge; +use function count; +use function in_array; +use function usleep; +use Generator; +use PHPUnit\Event\CollectingEmitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade as EventFacade; +use PHPUnit\Runner\Phpt\TestCase as PhptTestCase; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; +use PHPUnit\Util\PHP\Result; +use PHPUnit\Util\PHP\RunningJob; + +/** + * Runs PHPT tests concurrently, each as its own child process, in the main + * PHPUnit process. + * + * A PHPT test already runs its --SKIPIF--, --FILE--, and --CLEAN-- sections in + * child processes; routing it through a PersistentWorker would only wrap those + * children in a further process, and a child process spawned from within a + * worker hangs on Windows because it inherits the worker's control-channel + * handles. This runner therefore drives the PHPT tests directly: it advances up + * to a fixed number of them at a time, each represented by the generator that + * PhptTestCase::execute() returns, and starts the next section's child process + * for a test as soon as the previous one has finished. + * + * Because the events of a PHPT test must reach the parent's output and result + * subsystem in suite order — not in the order in which the concurrently running + * tests happen to finish — each test writes its events into its own collecting + * emitter. The collected events are handed to the caller, which replays them at + * the test's suite index through the same ResultAggregator that orders the + * worker units. + * + * The --FILE-- section's child redirects its standard error onto its standard + * output, which is captured in a file rather than a pipe; there is therefore no + * stream to wait on with stream_select(), so the runner polls each child's + * liveness instead, draining the pipes of the sections that do use them so that + * a child never blocks on a full pipe buffer. + * + * A test may declare conflict keys with a --CONFLICTS-- section: while a test + * that conflicts with key K is running, no other test that conflicts with K is + * started. The reserved key "all" conflicts with every other test, so a test + * that declares it runs entirely on its own; such tests are ordered last and + * run once the others have drained, mirroring how run-tests.php defers them + * until a single worker remains. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class PhptRunner +{ + private readonly JobRunner $jobRunner; + + /** + * @var positive-int + */ + private readonly int $concurrency; + + /** + * @param positive-int $concurrency + */ + public function __construct(JobRunner $jobRunner, int $concurrency) + { + $this->jobRunner = $jobRunner; + $this->concurrency = $concurrency; + } + + /** + * Run the given PHPT units, invoking the callback once for each unit as it + * finishes (in completion order, not suite order) with its suite index and + * the events it collected. + * + * @param list $units + * @param callable(non-negative-int, EventCollection): void $onCompleted + */ + public function run(array $units, callable $onCompleted): void + { + // Tests that conflict with "all" run on their own; ordering them last + // lets the others start first, so an "all" test only runs once the + // queue ahead of it has drained. + $queue = []; + $deferred = []; + + foreach ($units as $unit) { + if (in_array('all', $unit->conflicts(), true)) { + $deferred[] = $unit; + + continue; + } + + $queue[] = $unit; + } + + $queue = array_merge($queue, $deferred); + + $active = []; + + // The conflict keys currently held by a running test, and whether a + // test that conflicts with "all" is running (which blocks every other + // test from starting). + $activeConflicts = []; + $exclusive = false; + $nextId = 0; + + while ($queue !== [] || $active !== []) { + foreach ($queue as $position => $unit) { + if (count($active) >= $this->concurrency || $exclusive) { + break; + } + + if (!$this->canStart($unit, $active, $activeConflicts)) { + continue; + } + + unset($queue[$position]); + + $collector = EventFacade::instance()->collectingEmitter(); + $generator = new PhptTestCase($unit->file())->execute($collector->emitter()); + + $generator->rewind(); + + if (!$generator->valid()) { + // The test produced its events without running any child + // process — a parse error, or a skip decided in-process. It + // is already finished, so it reserves no conflict keys. + $onCompleted($unit->index(), $collector->flush()); + + continue; + } + + $this->reserve($unit, $activeConflicts, $exclusive); + + $active[$nextId] = [ + 'unit' => $unit, + 'generator' => $generator, + 'collector' => $collector, + 'job' => $this->jobRunner->startAsync($generator->current()), + ]; + + $nextId++; + } + + if ($active === []) { + continue; + } + + $this->harvest($active, $onCompleted, $activeConflicts, $exclusive); + } + } + + /** + * Whether the unit may be started right now: a unit that conflicts with + * "all" may start only when nothing else is running, and any other unit may + * start only when none of its conflict keys are currently held. + * + * @param array, collector: CollectingEmitter, job: RunningJob}> $active + * @param array $activeConflicts + */ + private function canStart(PhptWorkUnit $unit, array $active, array $activeConflicts): bool + { + if (in_array('all', $unit->conflicts(), true)) { + return $active === []; + } + + foreach ($unit->conflicts() as $key) { + if (isset($activeConflicts[$key])) { + return false; + } + } + + return true; + } + + /** + * Record the conflict keys the unit holds while it runs. The reserved key + * "all" is tracked through the exclusive flag rather than the key map, + * because it blocks every other test rather than one sharing its key. + * + * @param array $activeConflicts + */ + private function reserve(PhptWorkUnit $unit, array &$activeConflicts, bool &$exclusive): void + { + foreach ($unit->conflicts() as $key) { + if ($key === 'all') { + $exclusive = true; + + continue; + } + + $activeConflicts[$key] = true; + } + } + + /** + * Release the conflict keys the unit held once it has finished. + * + * @param array $activeConflicts + */ + private function release(PhptWorkUnit $unit, array &$activeConflicts, bool &$exclusive): void + { + foreach ($unit->conflicts() as $key) { + if ($key === 'all') { + $exclusive = false; + + continue; + } + + unset($activeConflicts[$key]); + } + } + + /** + * @param array, collector: CollectingEmitter, job: RunningJob}> $active + * @param callable(non-negative-int, EventCollection): void $onCompleted + * @param array $activeConflicts + */ + private function harvest(array &$active, callable $onCompleted, array &$activeConflicts, bool &$exclusive): void + { + $progressed = false; + + foreach ($active as $id => $task) { + // Drain whatever the child has produced on its pipes so that it + // never blocks writing into a full pipe buffer while we wait. + $task['job']->consume(); + + if ($task['job']->isRunning()) { + continue; + } + + $progressed = true; + + $generator = $task['generator']; + + $generator->send($task['job']->wait()); + + if ($generator->valid()) { + // The test's next section has to run in a child process too. + $active[$id]['job'] = $this->jobRunner->startAsync($generator->current()); + + continue; + } + + $onCompleted($task['unit']->index(), $task['collector']->flush()); + + $this->release($task['unit'], $activeConflicts, $exclusive); + + unset($active[$id]); + } + + if (!$progressed) { + usleep(1000); + } + } +} diff --git a/src/Runner/Parallel/PhptWorkUnit.php b/src/Runner/Parallel/PhptWorkUnit.php new file mode 100644 index 00000000000..aa5b97da18d --- /dev/null +++ b/src/Runner/Parallel/PhptWorkUnit.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +/** + * A unit of work for a single PHPT test, identified by the path of its .phpt + * file. A PHPT test is not a PHPUnit\Framework\TestCase and carries no test + * data, so the worker reconstructs it from nothing more than this file path. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final readonly class PhptWorkUnit implements WorkUnit +{ + /** + * @var non-negative-int + */ + private int $index; + + /** + * @var non-empty-string + */ + private string $file; + + /** + * @var list + */ + private array $conflicts; + + /** + * @param non-negative-int $index + * @param non-empty-string $file + * @param list $conflicts + */ + public function __construct(int $index, string $file, array $conflicts = []) + { + $this->index = $index; + $this->file = $file; + $this->conflicts = $conflicts; + } + + /** + * @return non-negative-int + */ + public function index(): int + { + return $this->index; + } + + /** + * @return non-empty-string + */ + public function file(): string + { + return $this->file; + } + + /** + * The conflict keys declared by the test's --CONFLICTS-- section. While a + * test that conflicts with key K is running, no other test that conflicts + * with K may run. The reserved key "all" conflicts with every other test, + * so a test that declares it runs on its own. + * + * @return list + */ + public function conflicts(): array + { + return $this->conflicts; + } + + /** + * @return non-empty-string + */ + public function name(): string + { + return $this->file; + } +} diff --git a/src/Runner/Parallel/ResultAggregator.php b/src/Runner/Parallel/ResultAggregator.php new file mode 100644 index 00000000000..a4316081bc2 --- /dev/null +++ b/src/Runner/Parallel/ResultAggregator.php @@ -0,0 +1,205 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function property_exists; +use function sprintf; +use function unserialize; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade; +use PHPUnit\Framework\TestRunner\ChildProcessResultEnvelope; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestRunner\TestResult\PassedTests; +use stdClass; + +/** + * Collects the results that the workers produce and replays them into the + * parent process' event subsystem. + * + * Workers finish their units out of order, but every printer, logger, and + * report assumes the suite-ordered arrival it sees in sequential mode. The + * aggregator therefore buffers each finished unit and forwards it only once all + * of the units that precede it in suite order have been forwarded; the result + * is an event stream that is byte-for-byte identical to the sequential one, + * which keeps CI diffs stable. + * + * Forwarding a unit means replaying its collected event stream through the + * parent dispatcher, importing the tests it recorded as passed, and merging its + * code coverage. Because every user-facing output is a downstream subscriber of + * these events, the parent's entire output, logging, and result subsystem works + * unchanged. + * + * Some units do not run in a worker but in the main process (see + * ParallelTestRunner): a unit attributed with #[DoNotRunInParallel], one that + * needs process isolation, or one whose data cannot be serialized. Such a unit + * is registered with its suite index and run, by the aggregator, at the moment + * its index comes up in the release sequence. Running it there — between the + * units that precede and follow it in suite order — lets its events reach the + * dispatcher live and still in global suite order, so the ordering guarantee + * holds for every output format, not just the ones that re-sort. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class ResultAggregator +{ + private readonly Facade $eventFacade; + private readonly Emitter $emitter; + private readonly PassedTests $passedTests; + private readonly CodeCoverage $codeCoverage; + + /** + * @var array + */ + private array $buffer = []; + + /** + * @var array + */ + private array $inProcessRunners = []; + + /** + * @var non-negative-int + */ + private int $nextIndex = 0; + + public function __construct(Facade $eventFacade, Emitter $emitter, PassedTests $passedTests, CodeCoverage $codeCoverage) + { + $this->eventFacade = $eventFacade; + $this->emitter = $emitter; + $this->passedTests = $passedTests; + $this->codeCoverage = $codeCoverage; + } + + /** + * Register a unit that is to be run, in the main process, at the point its + * suite index comes up in the release sequence. + * + * @param non-negative-int $index + * @param callable():void $runner + */ + public function registerInProcessUnit(int $index, callable $runner): void + { + $this->inProcessRunners[$index] = $runner; + } + + /** + * Accept a finished worker unit and release every unit that has now become + * releasable in suite order. + */ + public function add(CompletedWorkUnit $completed): void + { + $this->buffer[$completed->unit()->index()] = $completed; + + $this->release(); + } + + /** + * Release every unit that is releasable in suite order, running registered + * in-process units in place as their index comes up. Called both after a + * worker finishes and directly by the runner, so that in-process units that + * precede or follow all worker units are run even when no worker completion + * drives the release. + */ + public function flush(): void + { + $this->release(); + } + + private function release(): void + { + while (true) { + if (isset($this->buffer[$this->nextIndex])) { + $this->forward($this->buffer[$this->nextIndex]); + + unset($this->buffer[$this->nextIndex]); + + $this->nextIndex++; + + continue; + } + + if (isset($this->inProcessRunners[$this->nextIndex])) { + $runner = $this->inProcessRunners[$this->nextIndex]; + + unset($this->inProcessRunners[$this->nextIndex]); + + $runner(); + + $this->nextIndex++; + + continue; + } + + break; + } + } + + private function forward(CompletedWorkUnit $completed): void + { + if ($completed->crashed()) { + $message = $completed->message(); + + if ($message === null || $message === '') { + $message = sprintf( + 'The worker process running %s ended unexpectedly', + $completed->unit()->name(), + ); + } + + $this->emitter->childProcessErrored(); + $this->emitter->testRunnerTriggeredPhpunitWarning($message); + + return; + } + + $serializedResult = ChildProcessResultEnvelope::verifyAndStripNonce( + $completed->serializedResult(), + $completed->nonce(), + ); + + if ($serializedResult === null) { + $this->emitter->childProcessErrored(); + $this->emitter->testRunnerTriggeredPhpunitWarning( + sprintf( + 'The result of the worker process running %s was tampered with or written by an unexpected process', + $completed->unit()->name(), + ), + ); + + return; + } + + $childResult = @unserialize($serializedResult); + + if (!$childResult instanceof stdClass || + !property_exists($childResult, 'events') || + !property_exists($childResult, 'passedTests') || + !$childResult->events instanceof EventCollection || + !$childResult->passedTests instanceof PassedTests) { + $this->emitter->childProcessErrored(); + $this->emitter->testRunnerTriggeredPhpunitWarning( + sprintf( + 'The worker process running %s ended unexpectedly', + $completed->unit()->name(), + ), + ); + + return; + } + + $this->eventFacade->forward($childResult->events); + $this->passedTests->import($childResult->passedTests); + + ChildProcessResultEnvelope::mergeCodeCoverage($childResult, $this->codeCoverage); + } +} diff --git a/src/Runner/Parallel/TestClassWorkUnit.php b/src/Runner/Parallel/TestClassWorkUnit.php new file mode 100644 index 00000000000..ca6ad234cc1 --- /dev/null +++ b/src/Runner/Parallel/TestClassWorkUnit.php @@ -0,0 +1,87 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Framework\IterativeTestSuite; +use PHPUnit\Framework\TestCase; + +/** + * A unit of work that bundles all of the selected tests of a single test class, + * kept together so that the class' shared fixtures (#[BeforeClass] / + * #[AfterClass]) and intra-class ordering are preserved when the unit is run by + * a single worker. + * + * A member of the unit is either a single test case or an IterativeTestSuite + * that aggregates the repetitions of a repeated test method or the attempts of + * a retried test method; such a suite travels as one atomic member so that its + * repetition and retry orchestration runs inside the worker. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final readonly class TestClassWorkUnit implements WorkUnit +{ + /** + * @var non-negative-int + */ + private int $index; + + /** + * @var class-string + */ + private string $className; + + /** + * @var list + */ + private array $tests; + + /** + * @param non-negative-int $index + * @param class-string $className + * @param list $tests + */ + public function __construct(int $index, string $className, array $tests) + { + $this->index = $index; + $this->className = $className; + $this->tests = $tests; + } + + /** + * @return non-negative-int + */ + public function index(): int + { + return $this->index; + } + + /** + * @return class-string + */ + public function className(): string + { + return $this->className; + } + + /** + * @return list + */ + public function tests(): array + { + return $this->tests; + } + + public function name(): string + { + return $this->className; + } +} diff --git a/src/Runner/Parallel/WorkUnit.php b/src/Runner/Parallel/WorkUnit.php new file mode 100644 index 00000000000..d46cbd93ede --- /dev/null +++ b/src/Runner/Parallel/WorkUnit.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +/** + * A unit of work distributed to a worker process. + * + * The index records the unit's position in the deterministic suite order; the + * ResultAggregator uses it to release the units' results in that order even + * though the workers finish them out of order. The name is a human-readable + * label used when a unit has to be reported on, for instance after a crash. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +interface WorkUnit +{ + /** + * @return non-negative-int + */ + public function index(): int; + + public function name(): string; +} diff --git a/src/Runner/Parallel/WorkerPool.php b/src/Runner/Parallel/WorkerPool.php new file mode 100644 index 00000000000..9371224ea9d --- /dev/null +++ b/src/Runner/Parallel/WorkerPool.php @@ -0,0 +1,174 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function array_shift; +use function usleep; + +/** + * A fixed-size pool of PersistentWorkers across which units of work are + * distributed. + * + * Distribution is a dynamic work-stealing queue rather than a static + * pre-partitioning of the units: whenever a worker becomes idle it pulls the + * next unit from the queue, which self-balances the load against stragglers. + * + * A single thread of control keeps all of the workers busy by polling them in + * rounds: each round it harvests every worker that has finished its unit, hands + * those finished units to the caller-supplied callback, tops the idle workers + * up with more work, and sleeps briefly before the next round if none finished. + * A worker signals completion through the filesystem (see PersistentWorker), + * which is polled rather than waited on with stream_select() because the latter + * does not work on the workers' output pipes on Windows. + * + * If a worker dies, the unit it was running is reported to the callback as a + * crashed unit and the dead worker is dropped from the pool; the remaining + * units are redistributed across the surviving workers. Should every worker + * die, the units that were never started are likewise reported as crashed so + * that the caller can account for all of them. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class WorkerPool +{ + /** + * How long to sleep, in microseconds, when a polling round finds that no + * worker has finished, so that waiting on the workers does not spin the CPU. + */ + private const int POLL_INTERVAL_MICROSECONDS = 1000; + + /** + * @var non-empty-list + */ + private readonly array $workers; + + /** + * @param non-empty-list $workers + */ + public function __construct(array $workers) + { + $this->workers = $workers; + } + + /** + * @throws WorkerException + */ + public function start(): void + { + foreach ($this->workers as $worker) { + $worker->start(); + } + } + + /** + * Run all of the given units across the pool, invoking the callback once + * for each unit as it finishes (in completion order, not suite order). + * + * @param list $units + * @param callable(CompletedWorkUnit):void $onCompleted + */ + public function run(array $units, callable $onCompleted): void + { + $queue = $units; + + while (true) { + $this->dispatchTo($queue, $onCompleted); + + $busy = $this->busyWorkers(); + + if ($busy === []) { + break; + } + + $progressed = false; + + foreach ($busy as $worker) { + $completed = $worker->poll(); + + if ($completed !== null) { + $onCompleted($completed); + + $progressed = true; + } + } + + // No worker finished this round: sleep briefly before polling again + // so that waiting for the workers does not spin the CPU. A worker + // that did finish is not slept on, so its freed slot is refilled at + // once on the next iteration. + if (!$progressed) { + usleep(self::POLL_INTERVAL_MICROSECONDS); + } + } + + // Every worker has died while units remain to be run: account for the + // abandoned units so that the caller does not silently lose them. + foreach ($queue as $unit) { + $onCompleted(new CompletedWorkUnit($unit, '', null, true)); + } + } + + public function stop(): void + { + foreach ($this->workers as $worker) { + $worker->stop(); + } + } + + /** + * Hand the next queued units to the idle, alive workers. + * + * A unit that cannot be dispatched — for instance because its test data + * cannot be serialized for transport to a worker — is reported to the + * callback as a crashed unit and skipped, so that one undispatchable unit + * does not abort the entire run or starve an otherwise idle worker. + * + * @param list $queue + * @param callable(CompletedWorkUnit):void $onCompleted + */ + private function dispatchTo(array &$queue, callable $onCompleted): void + { + foreach ($this->workers as $worker) { + if (!$worker->isAlive() || $worker->isBusy()) { + continue; + } + + while ($queue !== []) { + $unit = array_shift($queue); + + try { + $worker->dispatch($unit); + + break; + } catch (WorkerException $e) { + $onCompleted(new CompletedWorkUnit($unit, '', null, true, $e->getMessage())); + } + } + } + } + + /** + * @return list + */ + private function busyWorkers(): array + { + $busy = []; + + foreach ($this->workers as $worker) { + if ($worker->isAlive() && $worker->isBusy()) { + $busy[] = $worker; + } + } + + return $busy; + } +} diff --git a/src/Runner/Parallel/templates/worker.tpl b/src/Runner/Parallel/templates/worker.tpl new file mode 100644 index 00000000000..eee11bd447a --- /dev/null +++ b/src/Runner/Parallel/templates/worker.tpl @@ -0,0 +1,207 @@ +source()); +} + +(new PhpHandler)->handle(ConfigurationRegistry::get()->php()); + +if ('{bootstrap}' !== '') { + require_once '{bootstrap}'; +} + +$__phpunit_configuration = ConfigurationRegistry::get(); + +if ({collectCodeCoverageInformation}) { + CodeCoverage::instance()->init($__phpunit_configuration, CodeCoverageFilterRegistry::instance(), true); +} + +ErrorHandlerBootstrapper::bootstrap($__phpunit_configuration); + +// A unit of work is run as a TestSuite, whose run loop consults the test +// result facade to decide whether to stop. The facade lazily registers its +// collector as an event subscriber on first use, which is not possible once +// the event facade has been sealed for isolation. The collector is therefore +// created here, while the facade is still open; it never receives events in +// the worker (the parent process owns the authoritative test result), so the +// worker never decides to stop on its own. +TestResultFacade::init(); + +ob_end_clean(); + +function __phpunit_worker_build_test(string $className, object $descriptor): PHPUnit\Framework\TestCase +{ + $test = new $className($descriptor->methodName); + + $test->setData($descriptor->dataName, unserialize(base64_decode($descriptor->data))); + $test->setDependencyInput(unserialize(base64_decode($descriptor->dependencyInput))); + $test->setRepetition($descriptor->repetition, $descriptor->totalRepetitions); + $test->setAttempt($descriptor->attempt, $descriptor->maxAttempts); + + return $test; +} + +function __phpunit_worker_run_unit(object $command): string +{ + $dispatcher = Facade::instance()->initForIsolation( + PHPUnit\Event\Telemetry\HRTime::fromSecondsAndNanoseconds( + $command->offsetSeconds, + $command->offsetNanoseconds + ), + ); + + require_once $command->file; + + $suite = TestSuite::empty($command->className); + + foreach ($command->tests as $__phpunit_test) { + $className = $command->className; + + // A retried test method travels as its RetryTestSuite so that the + // retry orchestration runs inside the worker; additional attempts are + // built here, from the same descriptor as the first one. + if ($__phpunit_test->type === 'retry') { + $descriptor = $__phpunit_test->test; + + $factory = static function () use ($className, $descriptor): PHPUnit\Framework\TestCase + { + return __phpunit_worker_build_test($className, $descriptor); + }; + + $suite->addTest( + RetryTestSuite::fromTestCase( + $__phpunit_test->name, + $factory(), + $__phpunit_test->maxAttempts, + $factory, + ), + ); + + continue; + } + + // A repeated test method travels as its RepeatTestSuite so that the + // repetition orchestration (failure threshold, skipping of remaining + // repetitions) runs inside the worker. + if ($__phpunit_test->type === 'repeat') { + $repetitions = []; + + foreach ($__phpunit_test->tests as $descriptor) { + $repetitions[] = __phpunit_worker_build_test($className, $descriptor); + } + + $suite->addTest( + RepeatTestSuite::fromTests( + $__phpunit_test->name, + $repetitions, + $__phpunit_test->failureThreshold, + ), + ); + + continue; + } + + $suite->addTest(__phpunit_worker_build_test($className, $__phpunit_test)); + } + + $suite->run(); + + $codeCoverage = null; + + if (CodeCoverage::instance()->isActive()) { + $codeCoverage = CodeCoverage::instance()->codeCoverage(); + } + + $result = $command->nonce . serialize( + (object) [ + 'codeCoverage' => $codeCoverage, + 'events' => $dispatcher->flush(), + 'passedTests' => PassedTests::instance(), + ] + ); + + // Per-unit code coverage has been collected for this command and is about + // to be shipped to the parent process. It is cleared here so that the next + // unit executed by this worker does not ship it a second time. + if (CodeCoverage::instance()->isActive()) { + CodeCoverage::instance()->codeCoverage()->clear(); + } + + // Reset the stream that captures test output so that the next unit does + // not inherit the output of the unit that has just finished. + if (@rewind(STDOUT)) { + @ftruncate(STDOUT, 0); + } + + return $result; +} + +$__phpunit_input = fopen('php://stdin', 'rb'); + +while (($__phpunit_line = fgets($__phpunit_input)) !== false) { + $__phpunit_line = trim($__phpunit_line); + + if ($__phpunit_line === '') { + continue; + } + + $__phpunit_command = json_decode($__phpunit_line); + + if ($__phpunit_command->command === 'stop') { + break; + } + + $__phpunit_result = __phpunit_worker_run_unit($__phpunit_command); + + file_put_contents($__phpunit_command->resultFile, $__phpunit_result); + + // Signal completion through the filesystem rather than standard output: the + // parent polls for this file, and because it is created only after the + // result file has been fully written, its presence means the result is + // ready to be read. This avoids the parent having to read the worker's + // output pipe, which cannot be done without blocking on Windows. + file_put_contents($__phpunit_command->doneFile, $__phpunit_command->nonce); +} diff --git a/src/Runner/Phpt/Parser.php b/src/Runner/Phpt/Parser.php index 6e15c942b1a..980f5a228eb 100644 --- a/src/Runner/Phpt/Parser.php +++ b/src/Runner/Phpt/Parser.php @@ -20,6 +20,7 @@ use function is_readable; use function is_string; use function preg_match; +use function preg_replace; use function realpath; use function rtrim; use function str_contains; @@ -170,6 +171,33 @@ public function parseIniSection(array|string $content, array $ini = []): array return $ini; } + /** + * The conflict keys declared by a --CONFLICTS-- section: one key per line, + * with "#" starting a comment and blank lines ignored. + * + * @return list + */ + public function parseConflictsSection(string $content): array + { + $content = preg_replace('/#.*/', '', $content); + + assert($content !== null); + + $conflicts = []; + + foreach (explode("\n", trim($content)) as $line) { + $key = trim($line); + + if ($key === '') { + continue; + } + + $conflicts[] = $key; + } + + return $conflicts; + } + /** * @param non-empty-string $phptFile * @param array $sections diff --git a/src/Runner/Phpt/TestCase.php b/src/Runner/Phpt/TestCase.php index f14c4d45b0c..3eda0a6adbf 100644 --- a/src/Runner/Phpt/TestCase.php +++ b/src/Runner/Phpt/TestCase.php @@ -40,8 +40,10 @@ use function trim; use function unlink; use function unserialize; +use Generator; use PHPUnit\Event\Code\Phpt; use PHPUnit\Event\Code\ThrowableBuilder; +use PHPUnit\Event\Emitter; use PHPUnit\Event\Facade as EventFacade; use PHPUnit\Event\NoPreviousThrowableException; use PHPUnit\Framework\Assert; @@ -60,6 +62,7 @@ use PHPUnit\TextUI\Configuration\Registry as ConfigurationRegistry; use PHPUnit\Util\PHP\Job; use PHPUnit\Util\PHP\JobRunnerRegistry; +use PHPUnit\Util\PHP\Result; use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData; use SebastianBergmann\CodeCoverage\InvalidArgumentException; use SebastianBergmann\CodeCoverage\ReflectionException; @@ -141,8 +144,43 @@ public function count(): int */ public function run(): void { - $emitter = EventFacade::emitter(); - $parser = new Parser; + $generator = $this->execute(EventFacade::emitter()); + + $generator->rewind(); + + while ($generator->valid()) { + $generator->send(JobRunnerRegistry::run($generator->current())); + } + } + + /** + * The staged execution of this PHPT test as a generator that yields a Job + * for each section that has to run in a child process and is resumed with + * that job's Result. + * + * Driving the generator synchronously, as run() does, runs the sections one + * after another and is equivalent to ordinary sequential execution. The + * parallel test runner instead drives the generators of several PHPT tests + * at once, each writing its events to its own emitter, so that the child + * processes of independent PHPT tests run concurrently without any of them + * being nested inside a worker process. + * + * @throws \PHPUnit\Framework\Exception + * @throws \SebastianBergmann\Template\InvalidArgumentException + * @throws Exception + * @throws InvalidArgumentException + * @throws NoPreviousThrowableException + * @throws ReflectionException + * @throws TestIdMissingException + * @throws UnintentionallyCoveredCodeException + * + * @return Generator + * + * @internal This method is not covered by the backward compatibility promise for PHPUnit + */ + public function execute(Emitter $emitter): Generator + { + $parser = new Parser; $emitter->testPreparationStarted( $this->valueObjectForEvents(), @@ -185,7 +223,7 @@ public function run(): void $environmentVariables = $parser->parseEnvSection($sections['ENV']); } - if ($this->shouldTestBeSkipped($sections, $phpSettings)) { + if (yield from $this->executeSkipIf($emitter, $sections, $phpSettings)) { return; } @@ -234,21 +272,19 @@ public function run(): void // @codeCoverageIgnoreEnd } - $jobResult = JobRunnerRegistry::run( - new Job( - $code, - $this->stringifyIni($phpSettings), - $environmentVariables, - $arguments, - $input, - true, - ), + $jobResult = yield new Job( + $code, + $this->stringifyIni($phpSettings), + $environmentVariables, + $arguments, + $input, + true, ); - EventFacade::emitter()->childProcessFinished($jobResult->stdout(), $jobResult->stderr()); + $emitter->childProcessFinished($jobResult->stdout(), $jobResult->stderr()); if (TestResultFacade::wasInterrupted()) { - $this->runClean($sections, CodeCoverage::instance()->isActive()); + yield from $this->executeClean($emitter, $sections, CodeCoverage::instance()->isActive()); $emitter->testFinished($this->valueObjectForEvents(), 0); @@ -356,7 +392,7 @@ public function run(): void $emitter->testPassed($this->valueObjectForEvents()); } - $this->runClean($sections, CodeCoverage::instance()->isActive()); + yield from $this->executeClean($emitter, $sections, CodeCoverage::instance()->isActive()); $emitter->testFinished($this->valueObjectForEvents(), 1); } @@ -371,6 +407,8 @@ public function getName(): string /** * Returns a string representation of the test case. + * + * @return non-empty-string */ public function toString(): string { @@ -476,8 +514,10 @@ private function assertPhptExpectation(array $sections, string $output): void /** * @param array $sections * @param array|string> $settings + * + * @return Generator */ - private function shouldTestBeSkipped(array $sections, array $settings): bool + private function executeSkipIf(Emitter $emitter, array $sections, array $settings): Generator { if (!isset($sections['SKIPIF']) || $sections['SKIPIF'] === '') { return false; @@ -486,21 +526,19 @@ private function shouldTestBeSkipped(array $sections, array $settings): bool $skipIfCode = (new Renderer)->render($this->filename, $sections['SKIPIF']); if ($this->shouldRunInSubprocess($sections, $skipIfCode)) { - $jobResult = JobRunnerRegistry::run( - new Job( - $skipIfCode, - $this->stringifyIni($settings), - ), + $jobResult = yield new Job( + $skipIfCode, + $this->stringifyIni($settings), ); $output = $jobResult->stdout(); - EventFacade::emitter()->childProcessFinished($output, $jobResult->stderr()); + $emitter->childProcessFinished($output, $jobResult->stderr()); } else { $output = $this->runCodeInLocalSandbox($skipIfCode); } - $this->triggerRunnerWarningOnPhpErrors('SKIPIF', $output); + $this->triggerRunnerWarningOnPhpErrors($emitter, 'SKIPIF', $output); if (strncasecmp('skip', ltrim($output), 4) === 0) { $message = ''; @@ -513,12 +551,12 @@ private function shouldTestBeSkipped(array $sections, array $settings): bool $message = 'Skipped'; } - EventFacade::emitter()->testSkipped( + $emitter->testSkipped( $this->valueObjectForEvents(), $message, ); - EventFacade::emitter()->testFinished($this->valueObjectForEvents(), 0); + $emitter->testFinished($this->valueObjectForEvents(), 0); return true; } @@ -529,7 +567,7 @@ private function shouldTestBeSkipped(array $sections, array $settings): bool !str_contains($output, 'Fatal error:') && !in_array(SideEffect::STANDARD_OUTPUT, $sideEffects, true) && !in_array(SideEffect::SCOPE_POLLUTION, $sideEffects, true)) { - EventFacade::emitter()->testConsideredRisky( + $emitter->testConsideredRisky( $this->valueObjectForEvents(), 'SKIPIF section does not produce output that could result in the test being skipped', ); @@ -593,8 +631,10 @@ private function runCodeInLocalSandbox(string $code): string /** * @param array $sections + * + * @return Generator */ - private function runClean(array $sections, bool $collectCoverage): void + private function executeClean(Emitter $emitter, array $sections, bool $collectCoverage): Generator { if (!isset($sections['CLEAN']) || $sections['CLEAN'] === '') { return; @@ -603,21 +643,19 @@ private function runClean(array $sections, bool $collectCoverage): void $cleanCode = (new Renderer)->render($this->filename, $sections['CLEAN']); if ($this->shouldRunInSubprocess($sections, $cleanCode)) { - $jobResult = JobRunnerRegistry::run( - new Job( - $cleanCode, - $this->settings($collectCoverage), - ), + $jobResult = yield new Job( + $cleanCode, + $this->settings($collectCoverage), ); $output = $jobResult->stdout(); - EventFacade::emitter()->childProcessFinished($jobResult->stdout(), $jobResult->stderr()); + $emitter->childProcessFinished($jobResult->stdout(), $jobResult->stderr()); } else { $output = $this->runCodeInLocalSandbox($cleanCode); } - $this->triggerRunnerWarningOnPhpErrors('CLEAN', $output); + $this->triggerRunnerWarningOnPhpErrors($emitter, 'CLEAN', $output); } /** @@ -878,10 +916,10 @@ private function settings(bool $collectCoverage): array return $settings; } - private function triggerRunnerWarningOnPhpErrors(string $section, string $output): void + private function triggerRunnerWarningOnPhpErrors(Emitter $emitter, string $section, string $output): void { if (str_contains($output, 'Parse error:')) { - EventFacade::emitter()->testRunnerTriggeredPhpunitWarning( + $emitter->testRunnerTriggeredPhpunitWarning( sprintf( '%s section triggered a parse error: %s', $section, @@ -891,7 +929,7 @@ private function triggerRunnerWarningOnPhpErrors(string $section, string $output } if (str_contains($output, 'Fatal error:')) { - EventFacade::emitter()->testRunnerTriggeredPhpunitWarning( + $emitter->testRunnerTriggeredPhpunitWarning( sprintf( '%s section triggered a fatal error: %s', $section, diff --git a/src/TextUI/Application.php b/src/TextUI/Application.php index 7c5611bccc1..b5a2ac0dae0 100644 --- a/src/TextUI/Application.php +++ b/src/TextUI/Application.php @@ -252,13 +252,19 @@ public function run(array $argv): int if ($coverageInitializationStatus === CodeCoverageInitializationStatus::NOT_REQUESTED || $coverageInitializationStatus === CodeCoverageInitializationStatus::SUCCEEDED) { - $runner = new TestRunner; - - $runner->run( - $configuration, - $resultCache, - $testSuite, - ); + if ($configuration->numberOfParallelWorkers() > 1) { + new ParallelTestRunner()->run( + $configuration, + $resultCache, + $testSuite, + ); + } else { + new TestRunner()->run( + $configuration, + $resultCache, + $testSuite, + ); + } } $duration = $timer->stop(); diff --git a/src/TextUI/Configuration/Cli/Builder.php b/src/TextUI/Configuration/Cli/Builder.php index 75506f032c2..be6822338e3 100644 --- a/src/TextUI/Configuration/Cli/Builder.php +++ b/src/TextUI/Configuration/Cli/Builder.php @@ -113,6 +113,7 @@ final class Builder 'no-results', 'order-by=', 'process-isolation', + 'parallel=', 'do-not-report-useless-tests', 'random-order', 'random-order-seed=', @@ -365,6 +366,7 @@ public function fromParameters(array $parameters): Configuration $noResults = null; $noLogging = null; $processIsolation = null; + $numberOfParallelWorkers = null; $randomOrderSeed = null; $repeat = null; $retry = null; @@ -839,6 +841,25 @@ public function fromParameters(array $parameters): Configuration break; + case '--parallel': + if (!is_numeric($option[1]) || + (string) (int) $option[1] !== $option[1] || + (int) $option[1] < 1) { + EventFacade::emitter()->testRunnerTriggeredPhpunitWarning( + sprintf( + 'Option "--parallel %s" ignored because "%s" is not a positive integer', + $option[1], + $option[1], + ), + ); + + break; + } + + $numberOfParallelWorkers = (int) $option[1]; + + break; + case '--stderr': $stderr = true; @@ -1487,6 +1508,7 @@ public function fromParameters(array $parameters): Configuration $debug, $withTelemetry, $extensions, + $numberOfParallelWorkers, ); } diff --git a/src/TextUI/Configuration/Cli/Configuration.php b/src/TextUI/Configuration/Cli/Configuration.php index 9f7a0fcead2..ca0769cd3c1 100644 --- a/src/TextUI/Configuration/Cli/Configuration.php +++ b/src/TextUI/Configuration/Cli/Configuration.php @@ -208,6 +208,11 @@ */ private ?array $extensions; + /** + * @var ?positive-int + */ + private ?int $numberOfParallelWorkers; + /** * @param list $arguments * @param ?positive-int $diffContext @@ -222,8 +227,9 @@ * @param ?non-empty-list $testSuffixes * @param ?non-empty-list $coverageFilter * @param ?non-empty-list $extensions + * @param ?positive-int $numberOfParallelWorkers */ - public function __construct(array $arguments, ?string $testFilesFile, ?string $testIdFile, ?string $testIdFilter, ?bool $all, ?string $atLeastVersion, ?bool $backupGlobals, ?bool $backupStaticProperties, ?bool $beStrictAboutChangesToGlobalState, ?string $bootstrap, ?string $cacheDirectory, ?bool $cacheResult, bool $checkPhpConfiguration, bool $checkVersion, ?string $colors, null|int|string $columns, ?string $configurationFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4J, ?string $coverageHtml, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, ?bool $coverageTextShowUncoveredFiles, ?bool $coverageTextShowOnlySummary, ?string $coverageXml, ?bool $coverageXmlIncludeSource, ?bool $pathCoverage, ?bool $branchCoverage, bool $warmCoverageCache, ?int $defaultTimeLimit, ?int $diffContext, ?bool $disableCodeCoverageIgnore, ?bool $disableCoverageTargeting, ?bool $disallowTestOutput, ?bool $enforceTimeLimit, ?array $excludeGroups, ?int $executionOrder, ?int $executionOrderDefects, ?bool $failOnAllIssues, ?bool $failOnDeprecation, ?bool $failOnPhpunitDeprecation, ?bool $failOnPhpunitNotice, ?bool $failOnPhpunitWarning, ?bool $failOnEmptyTestSuite, ?bool $failOnIncomplete, ?bool $failOnNotice, ?bool $failOnRisky, ?bool $failOnSkipped, ?bool $failOnWarning, ?bool $doNotFailOnDeprecation, ?bool $doNotFailOnPhpunitDeprecation, ?bool $doNotFailOnPhpunitNotice, ?bool $doNotFailOnPhpunitWarning, ?bool $doNotFailOnEmptyTestSuite, ?bool $doNotFailOnIncomplete, ?bool $doNotFailOnNotice, ?bool $doNotFailOnRisky, ?bool $doNotFailOnSkipped, ?bool $doNotFailOnWarning, ?int $stopOnDefect, ?int $stopOnDeprecation, ?string $specificDeprecationToStopOn, ?int $stopOnError, ?int $stopOnFailure, ?int $stopOnIncomplete, ?int $stopOnNotice, ?int $stopOnRisky, ?int $stopOnSkipped, ?int $stopOnWarning, ?string $filter, ?string $excludeFilter, ?string $generateBaseline, ?string $useBaseline, bool $ignoreBaseline, bool $generateConfiguration, bool $migrateConfiguration, bool $validateConfiguration, ?array $groups, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, bool $help, ?string $includePath, ?array $iniSettings, ?string $junitLogfile, ?string $otrLogfile, ?bool $includeGitInformation, bool $listGroups, bool $listSuites, bool $listTestFiles, bool $listTestIds, bool $listTests, ?string $listTestsXml, ?bool $noCoverage, ?bool $noExtensions, ?bool $noOutput, ?bool $noProgress, ?bool $noResults, ?bool $noLogging, ?bool $processIsolation, ?int $randomOrderSeed, ?int $repeat, ?int $retry, ?bool $reportUselessTests, ?bool $resolveDependencies, ?bool $reverseList, ?bool $stderr, ?bool $strictCoverage, ?bool $requireCoverageContribution, ?string $teamcityLogfile, ?string $testdoxHtmlFile, ?string $testdoxTextFile, ?array $testSuffixes, ?string $testSuite, ?string $excludeTestSuite, bool $useDefaultConfiguration, ?bool $displayDetailsOnAllIssues, ?bool $displayDetailsOnIncompleteTests, ?bool $displayDetailsOnSkippedTests, ?bool $displayDetailsOnTestsThatTriggerDeprecations, ?bool $displayDetailsOnPhpunitDeprecations, ?bool $displayDetailsOnPhpunitNotices, ?bool $displayDetailsOnTestsThatTriggerErrors, ?bool $displayDetailsOnTestsThatTriggerNotices, ?bool $displayDetailsOnTestsThatTriggerWarnings, bool $version, ?array $coverageFilter, ?string $logEventsText, ?string $logEventsVerboseText, ?bool $printerCompact, ?bool $printerTeamCity, ?bool $testdoxPrinter, ?bool $testdoxPrinterSummary, bool $debug, bool $withTelemetry, ?array $extensions) + public function __construct(array $arguments, ?string $testFilesFile, ?string $testIdFile, ?string $testIdFilter, ?bool $all, ?string $atLeastVersion, ?bool $backupGlobals, ?bool $backupStaticProperties, ?bool $beStrictAboutChangesToGlobalState, ?string $bootstrap, ?string $cacheDirectory, ?bool $cacheResult, bool $checkPhpConfiguration, bool $checkVersion, ?string $colors, null|int|string $columns, ?string $configurationFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4J, ?string $coverageHtml, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, ?bool $coverageTextShowUncoveredFiles, ?bool $coverageTextShowOnlySummary, ?string $coverageXml, ?bool $coverageXmlIncludeSource, ?bool $pathCoverage, ?bool $branchCoverage, bool $warmCoverageCache, ?int $defaultTimeLimit, ?int $diffContext, ?bool $disableCodeCoverageIgnore, ?bool $disableCoverageTargeting, ?bool $disallowTestOutput, ?bool $enforceTimeLimit, ?array $excludeGroups, ?int $executionOrder, ?int $executionOrderDefects, ?bool $failOnAllIssues, ?bool $failOnDeprecation, ?bool $failOnPhpunitDeprecation, ?bool $failOnPhpunitNotice, ?bool $failOnPhpunitWarning, ?bool $failOnEmptyTestSuite, ?bool $failOnIncomplete, ?bool $failOnNotice, ?bool $failOnRisky, ?bool $failOnSkipped, ?bool $failOnWarning, ?bool $doNotFailOnDeprecation, ?bool $doNotFailOnPhpunitDeprecation, ?bool $doNotFailOnPhpunitNotice, ?bool $doNotFailOnPhpunitWarning, ?bool $doNotFailOnEmptyTestSuite, ?bool $doNotFailOnIncomplete, ?bool $doNotFailOnNotice, ?bool $doNotFailOnRisky, ?bool $doNotFailOnSkipped, ?bool $doNotFailOnWarning, ?int $stopOnDefect, ?int $stopOnDeprecation, ?string $specificDeprecationToStopOn, ?int $stopOnError, ?int $stopOnFailure, ?int $stopOnIncomplete, ?int $stopOnNotice, ?int $stopOnRisky, ?int $stopOnSkipped, ?int $stopOnWarning, ?string $filter, ?string $excludeFilter, ?string $generateBaseline, ?string $useBaseline, bool $ignoreBaseline, bool $generateConfiguration, bool $migrateConfiguration, bool $validateConfiguration, ?array $groups, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, bool $help, ?string $includePath, ?array $iniSettings, ?string $junitLogfile, ?string $otrLogfile, ?bool $includeGitInformation, bool $listGroups, bool $listSuites, bool $listTestFiles, bool $listTestIds, bool $listTests, ?string $listTestsXml, ?bool $noCoverage, ?bool $noExtensions, ?bool $noOutput, ?bool $noProgress, ?bool $noResults, ?bool $noLogging, ?bool $processIsolation, ?int $randomOrderSeed, ?int $repeat, ?int $retry, ?bool $reportUselessTests, ?bool $resolveDependencies, ?bool $reverseList, ?bool $stderr, ?bool $strictCoverage, ?bool $requireCoverageContribution, ?string $teamcityLogfile, ?string $testdoxHtmlFile, ?string $testdoxTextFile, ?array $testSuffixes, ?string $testSuite, ?string $excludeTestSuite, bool $useDefaultConfiguration, ?bool $displayDetailsOnAllIssues, ?bool $displayDetailsOnIncompleteTests, ?bool $displayDetailsOnSkippedTests, ?bool $displayDetailsOnTestsThatTriggerDeprecations, ?bool $displayDetailsOnPhpunitDeprecations, ?bool $displayDetailsOnPhpunitNotices, ?bool $displayDetailsOnTestsThatTriggerErrors, ?bool $displayDetailsOnTestsThatTriggerNotices, ?bool $displayDetailsOnTestsThatTriggerWarnings, bool $version, ?array $coverageFilter, ?string $logEventsText, ?string $logEventsVerboseText, ?bool $printerCompact, ?bool $printerTeamCity, ?bool $testdoxPrinter, ?bool $testdoxPrinterSummary, bool $debug, bool $withTelemetry, ?array $extensions, ?int $numberOfParallelWorkers = null) { $this->arguments = $arguments; $this->testFilesFile = $testFilesFile; @@ -363,6 +369,7 @@ public function __construct(array $arguments, ?string $testFilesFile, ?string $t $this->debug = $debug; $this->withTelemetry = $withTelemetry; $this->extensions = $extensions; + $this->numberOfParallelWorkers = $numberOfParallelWorkers; } /** @@ -2881,4 +2888,26 @@ public function extensions(): array return $this->extensions; } + + /** + * @phpstan-assert-if-true !null $this->numberOfParallelWorkers + */ + public function hasNumberOfParallelWorkers(): bool + { + return $this->numberOfParallelWorkers !== null; + } + + /** + * @throws Exception + * + * @return positive-int + */ + public function numberOfParallelWorkers(): int + { + if (!$this->hasNumberOfParallelWorkers()) { + throw new Exception; + } + + return $this->numberOfParallelWorkers; + } } diff --git a/src/TextUI/Configuration/Configuration.php b/src/TextUI/Configuration/Configuration.php index 74e39129bce..d0d4e75a378 100644 --- a/src/TextUI/Configuration/Configuration.php +++ b/src/TextUI/Configuration/Configuration.php @@ -478,6 +478,11 @@ */ private int $shortenArraysForExportThreshold; + /** + * @var positive-int + */ + private int $numberOfParallelWorkers; + /** * @param list $cliArguments * @param ?non-empty-string $testFilesFile @@ -553,8 +558,9 @@ * @param positive-int $numberOfTestsBeforeGarbageCollection * @param null|non-empty-string $generateBaseline * @param non-negative-int $shortenArraysForExportThreshold + * @param positive-int $numberOfParallelWorkers */ - public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $cacheResult, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testResultCacheFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, int $coverageHtmlLowUpperBound, int $coverageHtmlHighLowerBound, string $coverageHtmlColorSuccessLow, string $coverageHtmlColorSuccessLowDark, string $coverageHtmlColorSuccessMedium, string $coverageHtmlColorSuccessMediumDark, string $coverageHtmlColorSuccessHigh, string $coverageHtmlColorSuccessHighDark, string $coverageHtmlColorSuccessBar, string $coverageHtmlColorSuccessBarDark, string $coverageHtmlColorWarning, string $coverageHtmlColorWarningDark, string $coverageHtmlColorWarningBar, string $coverageHtmlColorWarningBarDark, string $coverageHtmlColorDanger, string $coverageHtmlColorDangerDark, string $coverageHtmlColorDangerBar, string $coverageHtmlColorDangerBarDark, string $coverageHtmlColorBreadcrumbs, string $coverageHtmlColorBreadcrumbsDark, ?string $coverageHtmlCustomCssFile, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, bool $coverageTextShowUncoveredFiles, bool $coverageTextShowOnlySummary, ?string $coverageXml, bool $coverageXmlIncludeSource, bool $pathCoverage, bool $branchCoverage, ?string $coverageDriver, bool $ignoreDeprecatedCodeUnitsFromCodeCoverage, bool $disableCodeCoverageIgnore, bool $disableCoverageTargeting, bool $failOnAllIssues, bool $failOnDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnPhpunitDeprecation, bool $doNotFailOnPhpunitNotice, bool $doNotFailOnPhpunitWarning, bool $doNotFailOnEmptyTestSuite, bool $doNotFailOnIncomplete, bool $doNotFailOnNotice, bool $doNotFailOnRisky, bool $doNotFailOnSkipped, bool $doNotFailOnWarning, int $stopOnDefect, int $stopOnDeprecation, ?string $specificDeprecationToStopOn, int $stopOnError, int $stopOnFailure, int $stopOnIncomplete, int $stopOnNotice, int $stopOnRisky, int $stopOnSkipped, int $stopOnWarning, bool $outputToStandardErrorStream, int $columns, bool $noExtensions, ?string $pharExtensionDirectory, array $extensionBootstrappers, bool $backupGlobals, bool $backupStaticProperties, bool $beStrictAboutChangesToGlobalState, bool $colors, bool $processIsolation, bool $enforceTimeLimit, int $defaultTimeLimit, int $diffContext, int $timeoutForSmallTests, int $timeoutForMediumTests, int $timeoutForLargeTests, bool $reportUselessTests, bool $strictCoverage, bool $requireCoverageContribution, bool $disallowTestOutput, bool $displayDetailsOnAllIssues, bool $displayDetailsOnIncompleteTests, bool $displayDetailsOnSkippedTests, bool $displayDetailsOnTestsThatTriggerDeprecations, bool $displayDetailsOnPhpunitDeprecations, bool $displayDetailsOnPhpunitNotices, bool $displayDetailsOnTestsThatTriggerErrors, bool $displayDetailsOnTestsThatTriggerNotices, bool $displayDetailsOnTestsThatTriggerWarnings, bool $reverseDefectList, bool $requireCoverageMetadata, bool $requireSealedMockObjects, bool $noProgress, bool $noResults, bool $noOutput, int $executionOrder, int $executionOrderDefects, bool $resolveDependencies, ?string $logfileTeamcity, ?string $logfileJunit, ?string $logfileOtr, bool $includeGitInformation, bool $includeGitInformationInOtrLogfile, ?string $logfileTestdoxHtml, ?string $logfileTestdoxText, ?string $logEventsText, ?string $logEventsVerboseText, bool $compactOutput, bool $teamCityOutput, bool $testDoxOutput, bool $testDoxOutputSummary, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, ?string $filter, ?string $excludeFilter, ?string $testIdFilterFile, ?string $testIdFilter, array $groups, array $excludeGroups, int $randomOrderSeed, int $repeat, int $retry, bool $includeUncoveredFiles, TestSuiteCollection $testSuite, string $includeTestSuite, string $excludeTestSuite, ?string $defaultTestSuite, bool $ignoreTestSelectionInXmlConfiguration, array $testSuffixes, Php $php, bool $controlGarbageCollector, int $numberOfTestsBeforeGarbageCollection, ?string $generateBaseline, bool $debug, bool $withTelemetry, int $shortenArraysForExportThreshold) + public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $cacheResult, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testResultCacheFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, int $coverageHtmlLowUpperBound, int $coverageHtmlHighLowerBound, string $coverageHtmlColorSuccessLow, string $coverageHtmlColorSuccessLowDark, string $coverageHtmlColorSuccessMedium, string $coverageHtmlColorSuccessMediumDark, string $coverageHtmlColorSuccessHigh, string $coverageHtmlColorSuccessHighDark, string $coverageHtmlColorSuccessBar, string $coverageHtmlColorSuccessBarDark, string $coverageHtmlColorWarning, string $coverageHtmlColorWarningDark, string $coverageHtmlColorWarningBar, string $coverageHtmlColorWarningBarDark, string $coverageHtmlColorDanger, string $coverageHtmlColorDangerDark, string $coverageHtmlColorDangerBar, string $coverageHtmlColorDangerBarDark, string $coverageHtmlColorBreadcrumbs, string $coverageHtmlColorBreadcrumbsDark, ?string $coverageHtmlCustomCssFile, ?string $coverageOpenClover, ?string $coveragePhp, ?string $coverageText, bool $coverageTextShowUncoveredFiles, bool $coverageTextShowOnlySummary, ?string $coverageXml, bool $coverageXmlIncludeSource, bool $pathCoverage, bool $branchCoverage, ?string $coverageDriver, bool $ignoreDeprecatedCodeUnitsFromCodeCoverage, bool $disableCodeCoverageIgnore, bool $disableCoverageTargeting, bool $failOnAllIssues, bool $failOnDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnPhpunitDeprecation, bool $doNotFailOnPhpunitNotice, bool $doNotFailOnPhpunitWarning, bool $doNotFailOnEmptyTestSuite, bool $doNotFailOnIncomplete, bool $doNotFailOnNotice, bool $doNotFailOnRisky, bool $doNotFailOnSkipped, bool $doNotFailOnWarning, int $stopOnDefect, int $stopOnDeprecation, ?string $specificDeprecationToStopOn, int $stopOnError, int $stopOnFailure, int $stopOnIncomplete, int $stopOnNotice, int $stopOnRisky, int $stopOnSkipped, int $stopOnWarning, bool $outputToStandardErrorStream, int $columns, bool $noExtensions, ?string $pharExtensionDirectory, array $extensionBootstrappers, bool $backupGlobals, bool $backupStaticProperties, bool $beStrictAboutChangesToGlobalState, bool $colors, bool $processIsolation, bool $enforceTimeLimit, int $defaultTimeLimit, int $diffContext, int $timeoutForSmallTests, int $timeoutForMediumTests, int $timeoutForLargeTests, bool $reportUselessTests, bool $strictCoverage, bool $requireCoverageContribution, bool $disallowTestOutput, bool $displayDetailsOnAllIssues, bool $displayDetailsOnIncompleteTests, bool $displayDetailsOnSkippedTests, bool $displayDetailsOnTestsThatTriggerDeprecations, bool $displayDetailsOnPhpunitDeprecations, bool $displayDetailsOnPhpunitNotices, bool $displayDetailsOnTestsThatTriggerErrors, bool $displayDetailsOnTestsThatTriggerNotices, bool $displayDetailsOnTestsThatTriggerWarnings, bool $reverseDefectList, bool $requireCoverageMetadata, bool $requireSealedMockObjects, bool $noProgress, bool $noResults, bool $noOutput, int $executionOrder, int $executionOrderDefects, bool $resolveDependencies, ?string $logfileTeamcity, ?string $logfileJunit, ?string $logfileOtr, bool $includeGitInformation, bool $includeGitInformationInOtrLogfile, ?string $logfileTestdoxHtml, ?string $logfileTestdoxText, ?string $logEventsText, ?string $logEventsVerboseText, bool $compactOutput, bool $teamCityOutput, bool $testDoxOutput, bool $testDoxOutputSummary, ?array $testsCovering, ?array $testsUsing, ?array $testsRequiringPhpExtension, ?string $filter, ?string $excludeFilter, ?string $testIdFilterFile, ?string $testIdFilter, array $groups, array $excludeGroups, int $randomOrderSeed, int $repeat, int $retry, bool $includeUncoveredFiles, TestSuiteCollection $testSuite, string $includeTestSuite, string $excludeTestSuite, ?string $defaultTestSuite, bool $ignoreTestSelectionInXmlConfiguration, array $testSuffixes, Php $php, bool $controlGarbageCollector, int $numberOfTestsBeforeGarbageCollection, ?string $generateBaseline, bool $debug, bool $withTelemetry, int $shortenArraysForExportThreshold, int $numberOfParallelWorkers) { $this->cliArguments = $cliArguments; $this->testFilesFile = $testFilesFile; @@ -713,6 +719,7 @@ public function __construct(array $cliArguments, ?string $testFilesFile, ?string $this->debug = $debug; $this->withTelemetry = $withTelemetry; $this->shortenArraysForExportThreshold = $shortenArraysForExportThreshold; + $this->numberOfParallelWorkers = $numberOfParallelWorkers; } /** @@ -2352,4 +2359,12 @@ public function shortenArraysForExportThreshold(): int { return $this->shortenArraysForExportThreshold; } + + /** + * @return positive-int + */ + public function numberOfParallelWorkers(): int + { + return $this->numberOfParallelWorkers; + } } diff --git a/src/TextUI/Configuration/Merger.php b/src/TextUI/Configuration/Merger.php index bbb0f308a95..6bd645a2f07 100644 --- a/src/TextUI/Configuration/Merger.php +++ b/src/TextUI/Configuration/Merger.php @@ -930,6 +930,12 @@ public function merge(CliConfiguration $cliConfiguration, XmlConfiguration $xmlC $retry = $cliConfiguration->retry(); } + $numberOfParallelWorkers = 1; + + if ($cliConfiguration->hasNumberOfParallelWorkers()) { + $numberOfParallelWorkers = $cliConfiguration->numberOfParallelWorkers(); + } + if ($xmlConfiguration->wasLoadedFromFile() && $xmlConfiguration->hasValidationErrors()) { if ((new SchemaDetector)->detect($xmlConfiguration->filename())->detected()) { EventFacade::emitter()->testRunnerTriggeredPhpunitDeprecation( @@ -1330,6 +1336,7 @@ public function merge(CliConfiguration $cliConfiguration, XmlConfiguration $xmlC $cliConfiguration->debug(), $cliConfiguration->withTelemetry(), $xmlConfiguration->phpunit()->shortenArraysForExportThreshold(), + $numberOfParallelWorkers, ); } diff --git a/src/TextUI/Help.php b/src/TextUI/Help.php index 6ac5d2458ac..da39ff52339 100644 --- a/src/TextUI/Help.php +++ b/src/TextUI/Help.php @@ -214,6 +214,7 @@ private function elements(): array 'Execution' => [ ['arg' => '--process-isolation', 'desc' => 'Run each test in a separate PHP process'], + ['arg' => '--parallel ', 'desc' => 'Run test classes in parallel using worker processes'], ['arg' => '--globals-backup', 'desc' => 'Backup and restore $GLOBALS for each test'], ['arg' => '--static-backup', 'desc' => 'Backup and restore static properties for each test'], ['spacer' => ''], diff --git a/src/TextUI/ParallelTestRunner.php b/src/TextUI/ParallelTestRunner.php new file mode 100644 index 00000000000..fee86b34f10 --- /dev/null +++ b/src/TextUI/ParallelTestRunner.php @@ -0,0 +1,674 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TextUI; + +use function assert; +use function get_parent_class; +use function gettype; +use function is_array; +use function is_object; +use function is_resource; +use function mt_srand; +use function serialize; +use function spl_object_id; +use PHPUnit\Event; +use PHPUnit\Framework\IterativeTestSuite; +use PHPUnit\Framework\PhptIterativeTestSuite; +use PHPUnit\Framework\Test; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Framework\TestSuite; +use PHPUnit\Metadata\Parser\Registry as MetadataRegistry; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\Runner\Exception as PhptException; +use PHPUnit\Runner\Parallel\CompletedWorkUnit; +use PHPUnit\Runner\Parallel\PersistentWorker; +use PHPUnit\Runner\Parallel\PhptRunner; +use PHPUnit\Runner\Parallel\PhptWorkUnit; +use PHPUnit\Runner\Parallel\ResultAggregator; +use PHPUnit\Runner\Parallel\TestClassWorkUnit; +use PHPUnit\Runner\Parallel\WorkerException; +use PHPUnit\Runner\Parallel\WorkerPool; +use PHPUnit\Runner\Parallel\WorkUnit; +use PHPUnit\Runner\Phpt\Parser; +use PHPUnit\Runner\Phpt\TestCase as PhptTestCase; +use PHPUnit\Runner\ResultCache\ResultCache; +use PHPUnit\Runner\TestSuiteSorter; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\TextUI\Configuration\Configuration; +use PHPUnit\Util\PHP\JobRunner; +use Throwable; + +/** + * Runs a test suite by distributing its test classes across a pool of worker + * processes that execute them concurrently. + * + * The distribution unit is one test class: all of the selected tests of a class + * are run together by a single worker, which preserves the class' shared + * fixtures (#[BeforeClass] / #[AfterClass]) and its intra-class ordering. + * + * Apart from the parallel execution itself, this runner is a drop-in + * replacement for the sequential TestRunner: it performs the same test suite + * sorting and filtering and emits the same test runner lifecycle events, so the + * parent process' output, logging, and result subsystem is unaffected. + * + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class ParallelTestRunner +{ + /** + * @throws RuntimeException + */ + public function run(Configuration $configuration, ResultCache $resultCache, TestSuite $suite): void + { + try { + Event\Facade::emitter()->testRunnerStarted(); + + if ($configuration->executionOrder() === TestSuiteSorter::ORDER_RANDOMIZED) { + mt_srand($configuration->randomOrderSeed()); + } + + if ($configuration->executionOrder() !== TestSuiteSorter::ORDER_DEFAULT || + $configuration->executionOrderDefects() !== TestSuiteSorter::ORDER_DEFAULT || + $configuration->resolveDependencies()) { + $resultCache->load(); + + new TestSuiteSorter($resultCache)->reorderTestsInSuite( + $suite, + $configuration->executionOrder(), + $configuration->resolveDependencies(), + $configuration->executionOrderDefects(), + ); + + Event\Facade::emitter()->testSuiteSorted( + $configuration->executionOrder(), + $configuration->executionOrderDefects(), + $configuration->resolveDependencies(), + ); + } + + (new TestSuiteFilterProcessor)->process($configuration, $suite); + + Event\Facade::emitter()->testRunnerExecutionStarted( + Event\TestSuite\TestSuiteBuilder::from($suite), + ); + + $collected = $this->collectUnits($suite); + + if ($collected['units'] !== [] || $collected['phpt'] !== [] || $collected['standalone'] !== []) { + $this->execute($configuration, $collected['units'], $collected['phpt'], $collected['standalone']); + } + + Event\Facade::emitter()->testRunnerExecutionFinished(); + Event\Facade::emitter()->testRunnerFinished(); + // @codeCoverageIgnoreStart + } catch (Throwable $t) { + throw new RuntimeException( + $t->getMessage(), + (int) $t->getCode(), + $t, + ); + // @codeCoverageIgnoreEnd + } + } + + /** + * Units are run in two phases. + * + * The parallel phase distributes across the worker pool every unit whose + * tests may run in a worker process: those that are neither attributed with + * #[DoNotRunInParallel] nor carry test data that cannot be serialized for + * transport to a worker. + * + * The in-process phase then runs the remaining units one at a time in the + * main PHPUnit process, after the parallel phase has finished so that they + * never overlap a worker. A unit runs here when it is attributed with + * #[DoNotRunInParallel] (the author has declared that its tests must not run + * alongside others, for instance because they share a process-global + * resource), when it is configured to run in a separate process (an + * isolation that a shared worker cannot provide but the main process can), + * or when its test data cannot be serialized for transport to a worker (in + * which case the main process is the only place it can run at all). Running + * in the main process is ordinary execution that behaves exactly as it would + * in sequential mode. + * + * PHPT tests are not PHPUnit\Framework\TestCase instances and cannot run in + * a worker, so they run concurrently in the main process, each as its own + * child process, honouring the conflict keys of their --CONFLICTS-- section. + * Any remaining standalone test — one that is neither a TestCase nor a PHPT + * test — is run in the main process at its own suite index. + * + * @param list $units + * @param list $phpt + * @param list $standalone + * + * @throws WorkerException + */ + private function execute(Configuration $configuration, array $units, array $phpt, array $standalone): void + { + $aggregator = new ResultAggregator( + Event\Facade::instance(), + Event\Facade::emitter(), + PassedTests::instance(), + CodeCoverage::instance(), + ); + + $parallel = []; + + $processIsolation = $configuration->processIsolation(); + + foreach ($units as $unit) { + if ($unit instanceof TestClassWorkUnit && + ($processIsolation || + $this->mustNotRunInParallel($unit) || + $this->requiresProcessIsolation($unit) || + !$this->canBeSerialized($unit))) { + // The unit keeps its global suite index; the aggregator runs it + // in the main process at the moment that index comes up in the + // release sequence, which keeps the output in global suite order. + $aggregator->registerInProcessUnit( + $unit->index(), + function () use ($unit): void + { + $this->runInProcess($unit); + }, + ); + + continue; + } + + $parallel[] = $unit; + } + + foreach ($standalone as $item) { + $test = $item['test']; + + $aggregator->registerInProcessUnit( + $item['index'], + static function () use ($test): void + { + $test->run(); + }, + ); + } + + // The PHPT tests run concurrently in the main process, each as its own + // child process rather than nested inside a worker. Every one is + // registered with the aggregator so that the events it collected are + // replayed at its suite index, interspersed in global suite order with + // the worker units and the in-process units. + if ($phpt !== []) { + $this->runPhpt($configuration, $phpt, $aggregator); + } + + // Run any in-process units that precede the first worker unit, then the + // worker units (whose completions drive the release of the in-process + // units interspersed among them), then any that follow the last one. + $aggregator->flush(); + + if ($parallel !== []) { + $this->runInParallel($parallel, $aggregator, $configuration->numberOfParallelWorkers()); + } + + $aggregator->flush(); + } + + /** + * Run the PHPT tests concurrently, each as its own child process in the + * main process, and register every one with the aggregator so that the + * events it collected are replayed at its suite index, in global suite + * order. + * + * @param non-empty-list $phpt + */ + private function runPhpt(Configuration $configuration, array $phpt, ResultAggregator $aggregator): void + { + $concurrency = $configuration->numberOfParallelWorkers(); + + if (CodeCoverage::instance()->isActive()) { + // The PHPT tests run in the main process and share its single code + // coverage instance, so they must not collect coverage at the same + // time; they are therefore run one at a time when coverage is on. + $concurrency = 1; + } + + $processor = new ChildProcessResultProcessor( + Event\Facade::instance(), + Event\Facade::emitter(), + PassedTests::instance(), + CodeCoverage::instance(), + ); + + new PhptRunner(new JobRunner($processor), $concurrency)->run( + $phpt, + static function (int $index, Event\EventCollection $events) use ($aggregator): void + { + $aggregator->registerInProcessUnit( + $index, + static function () use ($events): void + { + Event\Facade::instance()->forward($events); + }, + ); + + // Release everything that has become contiguous in suite order + // so that progress is reported as the PHPT tests finish, rather + // than buffered until the whole phase is done. PHPT tests finish + // out of order, so this releases nothing until the next test in + // suite order is among those that have finished. + $aggregator->flush(); + }, + ); + } + + /** + * @param non-empty-list $units + * @param positive-int $numberOfWorkers + * + * @throws WorkerException + */ + private function runInParallel(array $units, ResultAggregator $aggregator, int $numberOfWorkers): void + { + $pool = $this->createPool($numberOfWorkers); + + $pool->start(); + + try { + $pool->run( + $units, + static function (CompletedWorkUnit $completed) use ($aggregator): void + { + $aggregator->add($completed); + }, + ); + } finally { + $pool->stop(); + } + } + + /** + * Run all of a unit's tests in the main PHPUnit process, exactly as the + * sequential test runner would: the class is reassembled into a test suite + * and run, which preserves its shared fixtures and intra-class ordering and + * lets its events reach the parent's output and result subsystem directly. + */ + private function runInProcess(TestClassWorkUnit $unit): void + { + $suite = TestSuite::empty($unit->className()); + + foreach ($unit->tests() as $test) { + $suite->addTest($test); + } + + $suite->run(); + } + + /** + * Whether every test of the unit carries data that survives serialization + * for transport to a worker process. A unit that does not must be run in + * the main process instead. + * + * Two kinds of data do not survive: data that cannot be serialized at all + * (a closure, for example), which makes serialize() throw; and a resource, + * which serialize() silently turns into the integer 0 rather than rejecting + * — a test would then receive 0 in place of its resource and fail in a way + * that has nothing to do with the code under test. + */ + private function canBeSerialized(TestClassWorkUnit $unit): bool + { + foreach ($this->testCasesOf($unit) as $test) { + try { + serialize($test->providedData()); + serialize($test->dependencyInput()); + } catch (Throwable) { + return false; + } + + if ($this->containsResource($test->providedData()) || + $this->containsResource($test->dependencyInput())) { + return false; + } + } + + return true; + } + + /** + * @param array $seen + */ + private function containsResource(mixed $value, array &$seen = []): bool + { + if (is_resource($value)) { + return true; + } + + // is_resource() returns false for a resource that has already been + // closed, yet serialize() degrades it to 0 just the same, so a closed + // resource must be recognized here too. + if (gettype($value) === 'resource (closed)') { + return true; + } + + if (is_array($value)) { + foreach ($value as $item) { + if ($this->containsResource($item, $seen)) { + return true; + } + } + + return false; + } + + if (is_object($value)) { + $id = spl_object_id($value); + + if (isset($seen[$id])) { + return false; + } + + $seen[$id] = $value; + + foreach ((array) $value as $item) { + if ($this->containsResource($item, $seen)) { + return true; + } + } + } + + return false; + } + + /** + * A work unit is the whole of a test class, so a single test method that is + * attributed with #[DoNotRunInParallel] excludes its entire class from the + * parallel phase. + */ + private function mustNotRunInParallel(TestClassWorkUnit $unit): bool + { + $className = $unit->className(); + + $class = $className; + + do { + if (MetadataRegistry::parser()->forClass($class)->isDoNotRunInParallel()->isNotEmpty()) { + return true; + } + } while (($class = get_parent_class($class)) !== false); + + foreach ($this->testCasesOf($unit) as $test) { + if (MetadataRegistry::parser()->forMethod($className, $test->name())->isDoNotRunInParallel()->isNotEmpty()) { + return true; + } + } + + return false; + } + + /** + * Whether any of the unit's tests is configured to run in a separate + * process. Such a test relies on process isolation that a shared worker + * cannot provide; it is therefore run in the main process, where the test + * runner spawns the isolated child process for it as usual. + */ + private function requiresProcessIsolation(TestClassWorkUnit $unit): bool + { + $className = $unit->className(); + + $class = $className; + + do { + if (MetadataRegistry::parser()->forClass($class)->isRunTestsInSeparateProcesses()->isNotEmpty()) { + return true; + } + } while (($class = get_parent_class($class)) !== false); + + foreach ($this->testCasesOf($unit) as $test) { + if (MetadataRegistry::parser()->forMethod($className, $test->name())->isRunInSeparateProcess()->isNotEmpty()) { + return true; + } + } + + return false; + } + + /** + * The test cases of a unit, with the test cases aggregated by an + * IterativeTestSuite member enumerated in its place. + * + * @return list + */ + private function testCasesOf(TestClassWorkUnit $unit): array + { + $testCases = []; + + foreach ($unit->tests() as $test) { + if ($test instanceof IterativeTestSuite) { + foreach ($test->tests() as $aggregated) { + assert($aggregated instanceof TestCase); + + $testCases[] = $aggregated; + } + + continue; + } + + $testCases[] = $test; + } + + return $testCases; + } + + /** + * @param positive-int $numberOfWorkers + */ + private function createPool(int $numberOfWorkers): WorkerPool + { + $processor = new ChildProcessResultProcessor( + Event\Facade::instance(), + Event\Facade::emitter(), + PassedTests::instance(), + CodeCoverage::instance(), + ); + + $jobRunner = new JobRunner($processor); + + $workers = [new PersistentWorker($jobRunner, 0)]; + + for ($id = 1; $id < $numberOfWorkers; $id++) { + $workers[] = new PersistentWorker($jobRunner, $id); + } + + return new WorkerPool($workers); + } + + /** + * Walk the suite and group the selected tests into units in suite order. + * + * The tests of a class are gathered into one TestClassWorkUnit, indexed by + * the order in which the class first appears. A PHPT test becomes a + * PhptWorkUnit of its own. Both kinds can run in a worker, so both are + * returned as parallel-eligible units. Any other kind of test — one that is + * neither a PHPUnit\Framework\TestCase nor a PHPT test — cannot be + * reconstructed in a worker and is returned as a standalone test to run in + * the main process. All draw from one shared index sequence so that they + * can be released together in global suite order. + * + * @return array{units: list, phpt: list, standalone: list} + */ + private function collectUnits(TestSuite $suite): array + { + /** @var array, array{index: non-negative-int, tests: list}> $byClass */ + $byClass = []; + + /** @var list}> $phpt */ + $phpt = []; + + /** @var list $standalone */ + $standalone = []; + + $index = 0; + + $this->collect($suite, $byClass, $phpt, $standalone, $index); + + $units = []; + + foreach ($byClass as $className => $group) { + $units[] = new TestClassWorkUnit($group['index'], $className, $group['tests']); + } + + $phptUnits = []; + + foreach ($phpt as $item) { + $phptUnits[] = new PhptWorkUnit($item['index'], $item['file'], $item['conflicts']); + } + + return [ + 'units' => $units, + 'phpt' => $phptUnits, + 'standalone' => $standalone, + ]; + } + + /** + * @param array, array{index: non-negative-int, tests: list}> $byClass + * @param list}> $phpt + * @param list $standalone + * @param non-negative-int $index + */ + private function collect(TestSuite $suite, array &$byClass, array &$phpt, array &$standalone, int &$index): void + { + foreach ($suite as $test) { + // The repetitions of a repeated PHPT test and the attempts of a + // retried PHPT test are orchestrated by their suite's runTests() + // method and must run sequentially, so the suite runs as one unit + // in the main process at its suite index. + if ($test instanceof PhptIterativeTestSuite) { + $standalone[] = [ + 'index' => $index, + 'test' => $test, + ]; + + $index++; + + continue; + } + + // The repetitions of a repeated test method and the attempts of a + // retried test method are orchestrated by their suite's runTests() + // method; the suite therefore travels as one atomic member of its + // class' work unit instead of being flattened into its tests. + if ($test instanceof IterativeTestSuite) { + $tests = $test->tests(); + + assert($tests !== [] && $tests[0] instanceof TestCase); + + $className = $tests[0]::class; + + if (!isset($byClass[$className])) { + $byClass[$className] = [ + 'index' => $index, + 'tests' => [], + ]; + + $index++; + } + + $byClass[$className]['tests'][] = $test; + + continue; + } + + if ($test instanceof TestSuite) { + $this->collect($test, $byClass, $phpt, $standalone, $index); + + continue; + } + + if ($test instanceof TestCase) { + $className = $test::class; + + if (!isset($byClass[$className])) { + $byClass[$className] = [ + 'index' => $index, + 'tests' => [], + ]; + + $index++; + } + + $byClass[$className]['tests'][] = $test; + + continue; + } + + if ($test instanceof PhptTestCase) { + $file = $test->toString(); + + // A PHPT test cannot carry the #[DoNotRunInParallel] attribute, + // so it declares any tests it must not run alongside with a + // --CONFLICTS-- section. The runner honours those conflict keys + // while running the PHPT tests concurrently in the main process. + $phpt[] = [ + 'index' => $index, + 'file' => $file, + 'conflicts' => $this->phptConflicts($file), + ]; + + $index++; + + continue; + } + + // Any other kind of test cannot be reconstructed in a worker and is + // run as a standalone unit in the main process. + // @codeCoverageIgnoreStart + $standalone[] = [ + 'index' => $index, + 'test' => $test, + ]; + + $index++; + // @codeCoverageIgnoreEnd + } + } + + /** + * The conflict keys a PHPT test declares with a --CONFLICTS-- section. While + * a test that conflicts with a key is running, no other test that conflicts + * with the same key may run; the reserved key "all" conflicts with every + * other test. A test with no such section declares no conflicts. + * + * @param non-empty-string $file + * + * @return list + */ + private function phptConflicts(string $file): array + { + $parser = new Parser; + + try { + $sections = $parser->parse($file); + // @codeCoverageIgnoreStart + } catch (PhptException) { + // A malformed PHPT cannot meaningfully declare conflicts; it is run + // anyway and reports its own parse error at its suite position. + return []; + // @codeCoverageIgnoreEnd + } + + if (!isset($sections['CONFLICTS'])) { + return []; + } + + return $parser->parseConflictsSection($sections['CONFLICTS']); + } +} diff --git a/src/Util/PHP/JobRunner.php b/src/Util/PHP/JobRunner.php index 47524b10484..a7763542045 100644 --- a/src/Util/PHP/JobRunner.php +++ b/src/Util/PHP/JobRunner.php @@ -89,6 +89,61 @@ public function runTestJob(Job $job, string $processResultFile, Test $test, ?str * @throws PhpProcessException */ public function run(Job $job): Result + { + return $this->startAsync($job)->wait(); + } + + /** + * Spawn the code of the given job as a long-running worker process whose + * standard input remains open as a control channel. + * + * The job's code is written to a temporary file that is executed by the + * worker process; in contrast to run(), the code is not piped through + * standard input, leaving it available for the caller to send subsequent + * commands to the worker. + * + * @throws PhpProcessException + */ + public function start(Job $job): RunningJob + { + $temporaryFile = tempnam(sys_get_temp_dir(), 'phpunit_'); + + if ($temporaryFile === false || + file_put_contents($temporaryFile, $job->code()) === false) { + // @codeCoverageIgnoreStart + throw new PhpProcessException( + 'Unable to write temporary file', + ); + // @codeCoverageIgnoreEnd + } + + return $this->startProcess($job, $temporaryFile); + } + + /** + * Start the given job as an asynchronous one-shot process. + * + * The job's code is piped through the process' standard input — exactly as + * the synchronous run() does, of which this is the non-blocking half — and + * its input, if the job carries a --STDIN-- section, is run from a temporary + * file instead so that standard input remains free to deliver that section. + * Standard input is closed once written; the returned RunningJob can be + * polled for completion and reaped with wait(). + * + * Feeding the code through standard input rather than from a temporary file + * (as start() does for the persistent worker, whose standard input is its + * control channel) keeps the child's __FILE__ out of the filesystem, which + * matters to tests that, for instance, restrict open_basedir. + * + * Unlike start(), the process' standard input is not left open as a control + * channel; unlike run(), the caller is not made to block until the process + * has terminated. This lets a caller keep several such processes running at + * the same time — for instance the PHPT test runner, which runs the child + * processes of independent PHPT tests concurrently. + * + * @throws PhpProcessException + */ + public function startAsync(Job $job): RunningJob { $temporaryFile = null; @@ -122,34 +177,7 @@ public function run(Job $job): Result $running->write($job->code()); $running->closeStdin(); - return $running->wait(); - } - - /** - * Spawn the code of the given job as a long-running worker process whose - * standard input remains open as a control channel. - * - * The job's code is written to a temporary file that is executed by the - * worker process; in contrast to run(), the code is not piped through - * standard input, leaving it available for the caller to send subsequent - * commands to the worker. - * - * @throws PhpProcessException - */ - public function start(Job $job): RunningJob - { - $temporaryFile = tempnam(sys_get_temp_dir(), 'phpunit_'); - - if ($temporaryFile === false || - file_put_contents($temporaryFile, $job->code()) === false) { - // @codeCoverageIgnoreStart - throw new PhpProcessException( - 'Unable to write temporary file', - ); - // @codeCoverageIgnoreEnd - } - - return $this->startProcess($job, $temporaryFile); + return $running; } /** @@ -309,9 +337,13 @@ private function buildCommand(Job $job, ?string $file): array } if ($job->hasArguments()) { - if ($file === null) { - $command[] = '--'; - } + // The arguments are separated from the PHP options by "--" so that + // the PHP CLI passes them to the script rather than trying to parse + // them as options of its own. This is required whether the code is + // read from a file (php -f file -- args) or from standard input + // (php -- args); without it, an argument such as "--columns=80" is + // rejected by PHP as an unknown option. + $command[] = '--'; foreach ($job->arguments() as $argument) { $command[] = trim($argument); diff --git a/src/Util/PHP/RunningJob.php b/src/Util/PHP/RunningJob.php index 8128cbb922f..98fbf1e2eeb 100644 --- a/src/Util/PHP/RunningJob.php +++ b/src/Util/PHP/RunningJob.php @@ -15,6 +15,7 @@ use function fwrite; use function is_resource; use function proc_close; +use function proc_get_status; use function rewind; use function stream_get_contents; use function stream_set_blocking; @@ -140,6 +141,22 @@ public function readableStreams(): array return $streams; } + /** + * Whether the worker process is still executing. Once it has terminated, + * wait() can be called to reap it without blocking. Intended for callers + * that cannot wait on the process' output streams — for instance because + * its output is redirected to a file rather than a pipe — and therefore + * poll its liveness instead. + */ + public function isRunning(): bool + { + if ($this->result !== null) { + return false; + } + + return proc_get_status($this->process)['running']; + } + /** * Read whatever output is currently available without blocking. Intended * to be called after stream_select() has reported one of the streams diff --git a/tests/_files/Metadata/Attribute/tests/DoNotRunInParallelTest.php b/tests/_files/Metadata/Attribute/tests/DoNotRunInParallelTest.php new file mode 100644 index 00000000000..1e1c3342ff2 --- /dev/null +++ b/tests/_files/Metadata/Attribute/tests/DoNotRunInParallelTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\Metadata\Attribute; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +#[DoNotRunInParallel] +final class DoNotRunInParallelTest extends TestCase +{ + #[DoNotRunInParallel] + public function testOne(): void + { + } +} diff --git a/tests/_files/parallel-worker/WorkerFirstTest.php b/tests/_files/parallel-worker/WorkerFirstTest.php new file mode 100644 index 00000000000..363bb36f163 --- /dev/null +++ b/tests/_files/parallel-worker/WorkerFirstTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelWorker; + +use PHPUnit\Framework\TestCase; + +final class WorkerFirstTest extends TestCase +{ + public function testStartsTheProcessLocalCounter(): void + { + $this->assertSame(1, WorkerProcessProbe::increment()); + } +} diff --git a/tests/_files/parallel-worker/WorkerProcessProbe.php b/tests/_files/parallel-worker/WorkerProcessProbe.php new file mode 100644 index 00000000000..f9e73d445f9 --- /dev/null +++ b/tests/_files/parallel-worker/WorkerProcessProbe.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelWorker; + +/** + * Holds process-local state so that tests can assert whether they are executed + * in the same process as a previous test. + */ +final class WorkerProcessProbe +{ + private static int $counter = 0; + + public static function increment(): int + { + self::$counter++; + + return self::$counter; + } + + public static function value(): int + { + return self::$counter; + } +} diff --git a/tests/_files/parallel-worker/WorkerSecondTest.php b/tests/_files/parallel-worker/WorkerSecondTest.php new file mode 100644 index 00000000000..a303133ce68 --- /dev/null +++ b/tests/_files/parallel-worker/WorkerSecondTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelWorker; + +use function fopen; +use function fwrite; +use PHPUnit\Framework\TestCase; + +final class WorkerSecondTest extends TestCase +{ + public function testThatWritesStrayOutputWithoutANewlineToTheControlChannel(): void + { + // Writes directly to the worker's control channel (file descriptor 1) + // without a trailing newline, so that the stray output fuses with the + // completion marker the worker appends on the same line. The parent + // must still recognise the marker. + $controlChannel = fopen('php://fd/1', 'wb'); + + fwrite($controlChannel, 'stray output without a trailing newline'); + + $this->assertTrue(true); + } + + public function testSeesTheStateLeftBehindByTheFirstTest(): void + { + // Passes only when this test shares a process with WorkerFirstTest, + // which is what running both tests in one persistent worker provides. + $this->assertSame(1, WorkerProcessProbe::value()); + $this->assertSame(2, WorkerProcessProbe::increment()); + } + + public function testThatFails(): void + { + $this->assertTrue(false, 'intentional failure inside a persistent worker'); + } + + public function testThatKillsTheWorkerProcess(): void + { + // Terminates the worker before it can report a result, exercising the + // parent's handling of a worker that dies in the middle of a test. + exit(1); + } +} diff --git a/tests/_files/parallel-worker/worker-skipped.phpt b/tests/_files/parallel-worker/worker-skipped.phpt new file mode 100644 index 00000000000..ca7b8e9726c --- /dev/null +++ b/tests/_files/parallel-worker/worker-skipped.phpt @@ -0,0 +1,10 @@ +--TEST-- +A PHPT test that is skipped in-process, without running a child process +--SKIPIF-- +  Run test classes in parallel using + worker processes --globals-backup  Backup and restore $GLOBALS for each test --static-backup  Backup and restore static properties for diff --git a/tests/end-to-end/_files/output-cli-usage.txt b/tests/end-to-end/_files/output-cli-usage.txt index c993b3c1aad..e46c297c308 100644 --- a/tests/end-to-end/_files/output-cli-usage.txt +++ b/tests/end-to-end/_files/output-cli-usage.txt @@ -46,6 +46,7 @@ Selection: Execution: --process-isolation Run each test in a separate PHP process + --parallel Run test classes in parallel using worker processes --globals-backup Backup and restore $GLOBALS for each test --static-backup Backup and restore static properties for each test diff --git a/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt b/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt index 0a8701b8259..e7d1c395574 100644 --- a/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt +++ b/tests/end-to-end/cli/validate-configuration/invalid-configuration.phpt @@ -1,5 +1,7 @@ --TEST-- phpunit --validate-configuration with invalid configuration file +--CONFLICTS-- +all --FILE-- + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelExecution; + +use PHPUnit\Framework\TestCase; + +final class FirstParallelTest extends TestCase +{ + public function testOne(): void + { + $this->assertTrue(true); + } + + public function testTwo(): void + { + $this->assertSame(2, 1 + 1); + } +} diff --git a/tests/end-to-end/parallel/_files/SecondParallelTest.php b/tests/end-to-end/parallel/_files/SecondParallelTest.php new file mode 100644 index 00000000000..ff6afa22fff --- /dev/null +++ b/tests/end-to-end/parallel/_files/SecondParallelTest.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelExecution; + +use PHPUnit\Framework\TestCase; + +final class SecondParallelTest extends TestCase +{ + public function testThree(): void + { + $this->assertSame(3, 1 + 2); + } + + public function testFour(): void + { + $this->assertSame(4, 5); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/_files/ClassLevelTest.php b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/ClassLevelTest.php new file mode 100644 index 00000000000..7a83e85cb4d --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/ClassLevelTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\DoNotRunInParallel; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +#[DoNotRunInParallel] +final class ClassLevelTest extends TestCase +{ + public function testClassLevel(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/_files/InheritedTest.php b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/InheritedTest.php new file mode 100644 index 00000000000..2f9ef4a0669 --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/InheritedTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\DoNotRunInParallel; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +#[DoNotRunInParallel] +abstract class AbstractSequentialTestCase extends TestCase +{ +} + +final class InheritedTest extends AbstractSequentialTestCase +{ + public function testInherited(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/_files/MethodLevelTest.php b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/MethodLevelTest.php new file mode 100644 index 00000000000..0106d2fe82e --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/MethodLevelTest.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\DoNotRunInParallel; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +final class MethodLevelTest extends TestCase +{ + public function testPlainMethod(): void + { + $this->assertTrue(true); + } + + #[DoNotRunInParallel] + public function testSequentialMethod(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/_files/PlainTest.php b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/PlainTest.php new file mode 100644 index 00000000000..c430f3c3425 --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/_files/PlainTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\DoNotRunInParallel; + +use PHPUnit\Framework\TestCase; + +final class PlainTest extends TestCase +{ + public function testPlain(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/do-not-run-in-parallel/do-not-run-in-parallel.phpt b/tests/end-to-end/parallel/do-not-run-in-parallel/do-not-run-in-parallel.phpt new file mode 100644 index 00000000000..0d759044550 --- /dev/null +++ b/tests/end-to-end/parallel/do-not-run-in-parallel/do-not-run-in-parallel.phpt @@ -0,0 +1,36 @@ +--TEST-- +phpunit --parallel=2 runs tests attributed with #[DoNotRunInParallel] sequentially and forwards all results in suite order +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +..... 5 / 5 (100%) + +Time: %s, Memory: %s + +Class Level (PHPUnit\TestFixture\DoNotRunInParallel\ClassLevel) + ✔ Class level + +Inherited (PHPUnit\TestFixture\DoNotRunInParallel\Inherited) + ✔ Inherited + +Method Level (PHPUnit\TestFixture\DoNotRunInParallel\MethodLevel) + ✔ Plain method + ✔ Sequential method + +Plain (PHPUnit\TestFixture\DoNotRunInParallel\Plain) + ✔ Plain + +OK (5 tests, 5 assertions) diff --git a/tests/end-to-end/parallel/invalid-value/_files/PlainTest.php b/tests/end-to-end/parallel/invalid-value/_files/PlainTest.php new file mode 100644 index 00000000000..d3b43069086 --- /dev/null +++ b/tests/end-to-end/parallel/invalid-value/_files/PlainTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelInvalidValue; + +use PHPUnit\Framework\TestCase; + +final class PlainTest extends TestCase +{ + public function testOne(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/invalid-value/parallel-cli-option-invalid-value.phpt b/tests/end-to-end/parallel/invalid-value/parallel-cli-option-invalid-value.phpt new file mode 100644 index 00000000000..fcf99d154c8 --- /dev/null +++ b/tests/end-to-end/parallel/invalid-value/parallel-cli-option-invalid-value.phpt @@ -0,0 +1,32 @@ +--TEST-- +--parallel with a value that is not a positive integer triggers a warning and falls back to sequential execution +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit Started (PHPUnit %s using %s) +Test Runner Triggered PHPUnit Warning (Option "--parallel 0" ignored because "0" is not a positive integer) +Test Runner Configured +Event Facade Sealed +Test Suite Loaded (1 test) +Test Runner Started +Test Suite Sorted +Test Runner Execution Started (1 test) +Test Suite Started (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest, 1 test) +Test Preparation Started (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest::testOne) +Test Prepared (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest::testOne) +Test Passed (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest::testOne) +Test Finished (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest::testOne) +Test Suite Finished (PHPUnit\TestFixture\ParallelInvalidValue\PlainTest, 1 test) +Test Runner Execution Finished +Test Runner Finished +PHPUnit Finished (Shell Exit Code: 1) diff --git a/tests/end-to-end/parallel/non-serializable/_files/NonSerializableDataTest.php b/tests/end-to-end/parallel/non-serializable/_files/NonSerializableDataTest.php new file mode 100644 index 00000000000..a07a3c83a6e --- /dev/null +++ b/tests/end-to-end/parallel/non-serializable/_files/NonSerializableDataTest.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelNonSerializable; + +use function fopen; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class NonSerializableDataTest extends TestCase +{ + public static function resourceProvider(): array + { + return [ + [fopen('php://memory', 'r')], + ]; + } + + public static function closureProvider(): array + { + return [ + [static fn (): bool => true], + ]; + } + + #[DataProvider('resourceProvider')] + public function testReceivesAnOpenResource(mixed $resource): void + { + $this->assertIsResource($resource); + } + + #[DataProvider('closureProvider')] + public function testReceivesACallable(mixed $callable): void + { + $this->assertIsCallable($callable); + } +} diff --git a/tests/end-to-end/parallel/non-serializable/non-serializable.phpt b/tests/end-to-end/parallel/non-serializable/non-serializable.phpt new file mode 100644 index 00000000000..f5608d0f6fe --- /dev/null +++ b/tests/end-to-end/parallel/non-serializable/non-serializable.phpt @@ -0,0 +1,27 @@ +--TEST-- +phpunit --parallel=2 runs tests whose data cannot be serialized in the main process instead of a worker +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +Non Serializable Data (PHPUnit\TestFixture\ParallelNonSerializable\NonSerializableData) + ✔ Receives an open resource with data set #0 + ✔ Receives a callable with data set #0 + +OK (2 tests, 2 assertions) diff --git a/tests/end-to-end/parallel/ordering/_files/FirstOrderingTest.php b/tests/end-to-end/parallel/ordering/_files/FirstOrderingTest.php new file mode 100644 index 00000000000..4f42c0ef542 --- /dev/null +++ b/tests/end-to-end/parallel/ordering/_files/FirstOrderingTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelOrdering; + +use PHPUnit\Framework\TestCase; + +final class FirstOrderingTest extends TestCase +{ + public function testFirst(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/ordering/_files/SecondOrderingTest.php b/tests/end-to-end/parallel/ordering/_files/SecondOrderingTest.php new file mode 100644 index 00000000000..6e3b7d8f1ca --- /dev/null +++ b/tests/end-to-end/parallel/ordering/_files/SecondOrderingTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelOrdering; + +use PHPUnit\Framework\Attributes\DoNotRunInParallel; +use PHPUnit\Framework\TestCase; + +#[DoNotRunInParallel] +final class SecondOrderingTest extends TestCase +{ + public function testSecond(): void + { + $this->assertSame(1, 2); + } +} diff --git a/tests/end-to-end/parallel/ordering/_files/ThirdOrderingTest.php b/tests/end-to-end/parallel/ordering/_files/ThirdOrderingTest.php new file mode 100644 index 00000000000..ab48141e0fe --- /dev/null +++ b/tests/end-to-end/parallel/ordering/_files/ThirdOrderingTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelOrdering; + +use PHPUnit\Framework\TestCase; + +final class ThirdOrderingTest extends TestCase +{ + public function testThird(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/ordering/ordering.phpt b/tests/end-to-end/parallel/ordering/ordering.phpt new file mode 100644 index 00000000000..3e65f29c290 --- /dev/null +++ b/tests/end-to-end/parallel/ordering/ordering.phpt @@ -0,0 +1,30 @@ +--TEST-- +phpunit --parallel=2 forwards results in global suite order even when an in-process unit is interspersed among worker units +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.F. 3 / 3 (100%) + +Time: %s, Memory: %s + +There was 1 failure: + +1) PHPUnit\TestFixture\ParallelOrdering\SecondOrderingTest::testSecond +Failed asserting that 2 is identical to 1. + +%sSecondOrderingTest.php:20 + +FAILURES! +Tests: 3, Assertions: 3, Failures: 1. diff --git a/tests/end-to-end/parallel/parallel-execution.phpt b/tests/end-to-end/parallel/parallel-execution.phpt new file mode 100644 index 00000000000..261ff4210f3 --- /dev/null +++ b/tests/end-to-end/parallel/parallel-execution.phpt @@ -0,0 +1,37 @@ +--TEST-- +phpunit --parallel=2 runs each test class in a worker process and forwards the results in suite order +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +...F 4 / 4 (100%) + +Time: %s, Memory: %s + +First Parallel (PHPUnit\TestFixture\ParallelExecution\FirstParallel) + ✔ One + ✔ Two + +Second Parallel (PHPUnit\TestFixture\ParallelExecution\SecondParallel) + ✔ Three + ✘ Four + │ + │ Failed asserting that 5 is identical to 4. + │ + │ %sSecondParallelTest.php:23 + │ + +FAILURES! +Tests: 4, Assertions: 4, Failures: 1. diff --git a/tests/end-to-end/parallel/phpt-conflicts/_files/also-conflicts-with-all-phpt-test.phpt b/tests/end-to-end/parallel/phpt-conflicts/_files/also-conflicts-with-all-phpt-test.phpt new file mode 100644 index 00000000000..07565a72566 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-conflicts/_files/also-conflicts-with-all-phpt-test.phpt @@ -0,0 +1,9 @@ +--TEST-- +A second PHPT test that opts out of parallel execution with a --CONFLICTS-- section listing "all" +--CONFLICTS-- +all +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +... 3 / 3 (100%) + +Time: %s, Memory: %s + +OK (3 tests, 3 assertions) diff --git a/tests/end-to-end/parallel/phpt-conflicts/phpt-conflicts-with-shared-key.phpt b/tests/end-to-end/parallel/phpt-conflicts/phpt-conflicts-with-shared-key.phpt new file mode 100644 index 00000000000..4b2bb373f8d --- /dev/null +++ b/tests/end-to-end/parallel/phpt-conflicts/phpt-conflicts-with-shared-key.phpt @@ -0,0 +1,23 @@ +--TEST-- +phpunit --parallel=2 does not run two PHPT tests that share a --CONFLICTS-- key at the same time, but still runs both +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +OK (2 tests, 2 assertions) diff --git a/tests/end-to-end/parallel/phpt-coverage/_files/greeter.phpt b/tests/end-to-end/parallel/phpt-coverage/_files/greeter.phpt new file mode 100644 index 00000000000..1e308b15a10 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-coverage/_files/greeter.phpt @@ -0,0 +1,9 @@ +--TEST-- +A PHPT test that exercises a source file so that code coverage is collected for it +--FILE-- +greet(); +--EXPECT-- +Hello diff --git a/tests/end-to-end/parallel/phpt-coverage/_files/src/Greeter.php b/tests/end-to-end/parallel/phpt-coverage/_files/src/Greeter.php new file mode 100644 index 00000000000..8800b71eb2c --- /dev/null +++ b/tests/end-to-end/parallel/phpt-coverage/_files/src/Greeter.php @@ -0,0 +1,18 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelPhptCoverage; + +final class Greeter +{ + public function greet(): string + { + return 'Hello'; + } +} diff --git a/tests/end-to-end/parallel/phpt-coverage/phpt-runs-one-at-a-time-under-coverage.phpt b/tests/end-to-end/parallel/phpt-coverage/phpt-runs-one-at-a-time-under-coverage.phpt new file mode 100644 index 00000000000..2daebd6a778 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-coverage/phpt-runs-one-at-a-time-under-coverage.phpt @@ -0,0 +1,40 @@ +--TEST-- +phpunit --parallel=2 runs PHPT tests one at a time when code coverage is active so they do not collide on the shared coverage +--SKIPIF-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +. 1 / 1 (100%) + +Time: %s, Memory: %s + +OK (1 test, 1 assertion) + + +Code Coverage Report: + %s + + Summary: + Classes: 100.00% (1/1) + Methods: 100.00% (1/1) + Lines: 100.00% (1/1) + +PHPUnit\TestFixture\ParallelPhptCoverage\Greeter + Methods: 100.00% ( 1/ 1) Lines: 100.00% ( 1/ 1) diff --git a/tests/end-to-end/parallel/phpt-with-classes/_files/SampleClassTest.php b/tests/end-to-end/parallel/phpt-with-classes/_files/SampleClassTest.php new file mode 100644 index 00000000000..877cf39652d --- /dev/null +++ b/tests/end-to-end/parallel/phpt-with-classes/_files/SampleClassTest.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelPhptWithClasses; + +use PHPUnit\Framework\TestCase; + +final class SampleClassTest extends TestCase +{ + public function testPasses(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/phpt-with-classes/_files/aaa-passing.phpt b/tests/end-to-end/parallel/phpt-with-classes/_files/aaa-passing.phpt new file mode 100644 index 00000000000..e33c1ae3c39 --- /dev/null +++ b/tests/end-to-end/parallel/phpt-with-classes/_files/aaa-passing.phpt @@ -0,0 +1,7 @@ +--TEST-- +A passing PHPT test run in parallel +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +..F 3 / 3 (100%) + +Time: %s, Memory: %s + +There was 1 failure: + +1) %sbbb-failing.phpt +Failed asserting that two strings are equal. +--- Expected ++++ Actual +@@ @@ +-'expected output' ++'actual output' + +%sbbb-failing.phpt:7 + +FAILURES! +Tests: 3, Assertions: 3, Failures: 1. diff --git a/tests/end-to-end/parallel/phpt/_files/sample-phpt-test.phpt b/tests/end-to-end/parallel/phpt/_files/sample-phpt-test.phpt new file mode 100644 index 00000000000..fc341e85b54 --- /dev/null +++ b/tests/end-to-end/parallel/phpt/_files/sample-phpt-test.phpt @@ -0,0 +1,7 @@ +--TEST-- +A PHPT test that is expected to run under parallel test execution +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +. 1 / 1 (100%) + +Time: %s, Memory: %s + +OK (1 test, 1 assertion) diff --git a/tests/end-to-end/parallel/random-order/_files/AlphaRandomOrderTest.php b/tests/end-to-end/parallel/random-order/_files/AlphaRandomOrderTest.php new file mode 100644 index 00000000000..1c6c7179c2a --- /dev/null +++ b/tests/end-to-end/parallel/random-order/_files/AlphaRandomOrderTest.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelRandomOrder; + +use PHPUnit\Framework\TestCase; + +final class AlphaRandomOrderTest extends TestCase +{ + public function testOne(): void + { + $this->assertTrue(true); + } + + public function testTwo(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/random-order/_files/BetaRandomOrderTest.php b/tests/end-to-end/parallel/random-order/_files/BetaRandomOrderTest.php new file mode 100644 index 00000000000..fd2b7d63425 --- /dev/null +++ b/tests/end-to-end/parallel/random-order/_files/BetaRandomOrderTest.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelRandomOrder; + +use PHPUnit\Framework\TestCase; + +final class BetaRandomOrderTest extends TestCase +{ + public function testThree(): void + { + $this->assertTrue(true); + } + + public function testFour(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/random-order/random-order.phpt b/tests/end-to-end/parallel/random-order/random-order.phpt new file mode 100644 index 00000000000..a51f5c2919f --- /dev/null +++ b/tests/end-to-end/parallel/random-order/random-order.phpt @@ -0,0 +1,25 @@ +--TEST-- +phpunit --parallel=2 --random-order seeds the randomizer and runs the reordered test classes across the worker pool +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s +Random Seed: 54321 + +.... 4 / 4 (100%) + +Time: %s, Memory: %s + +OK (4 tests, 4 assertions) diff --git a/tests/end-to-end/parallel/repeat-retry/_files/FlakyTest.php b/tests/end-to-end/parallel/repeat-retry/_files/FlakyTest.php new file mode 100644 index 00000000000..a7e3e212aee --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/_files/FlakyTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelRepeatRetry; + +use PHPUnit\Framework\Attributes\Retry; +use PHPUnit\Framework\TestCase; + +final class FlakyTest extends TestCase +{ + private static int $attempts = 0; + + #[Retry(3)] + public function testFlaky(): void + { + self::$attempts++; + + if (self::$attempts < 2) { + $this->fail('Flaky failure on attempt ' . self::$attempts); + } + + $this->assertTrue(true); + } + + public function testStable(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/repeat-retry/_files/FlakyWithDataProviderTest.php b/tests/end-to-end/parallel/repeat-retry/_files/FlakyWithDataProviderTest.php new file mode 100644 index 00000000000..dceb1f6675b --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/_files/FlakyWithDataProviderTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelRepeatRetry; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Retry; +use PHPUnit\Framework\TestCase; + +final class FlakyWithDataProviderTest extends TestCase +{ + /** + * @var array + */ + private static array $attempts = []; + + public static function provider(): array + { + return [ + 'one' => [1], + 'two' => [2], + ]; + } + + #[DataProvider('provider')] + #[Retry(3)] + public function testFlaky(int $value): void + { + if (!isset(self::$attempts[$value])) { + self::$attempts[$value] = 0; + } + + self::$attempts[$value]++; + + if ($value === 2 && self::$attempts[$value] < 3) { + $this->fail('Flaky failure on attempt ' . self::$attempts[$value]); + } + + $this->assertSame($value, $value); + } +} diff --git a/tests/end-to-end/parallel/repeat-retry/_files/RepeatFailingPhpt.phpt b/tests/end-to-end/parallel/repeat-retry/_files/RepeatFailingPhpt.phpt new file mode 100644 index 00000000000..640aa67b89f --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/_files/RepeatFailingPhpt.phpt @@ -0,0 +1,7 @@ +--TEST-- +PHPT that always fails +--FILE-- + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelRepeatRetry; + +use PHPUnit\Framework\TestCase; + +final class RepeatFailureTest extends TestCase +{ + private static int $runs = 0; + + public function testFailsOnSecondRepetition(): void + { + self::$runs++; + + if (self::$runs === 2) { + $this->fail('Failure on repetition ' . self::$runs); + } + + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/repeat-retry/repeat-in-worker.phpt b/tests/end-to-end/parallel/repeat-retry/repeat-in-worker.phpt new file mode 100644 index 00000000000..2e0a73a0749 --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/repeat-in-worker.phpt @@ -0,0 +1,32 @@ +--TEST-- +phpunit --repeat 3 --parallel=2 runs the repetitions of a test method inside the worker that runs its class and skips the remaining repetitions after a failure +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.FS 3 / 3 (100%) + +Time: %s, Memory: %s + +There was 1 failure: + +1) PHPUnit\TestFixture\ParallelRepeatRetry\RepeatFailureTest::testFailsOnSecondRepetition (repetition 2 of 3) +Failure on repetition 2 + +%sRepeatFailureTest.php:%d + +FAILURES! +Tests: 3, Assertions: 2, Failures: 1, Skipped: 1. diff --git a/tests/end-to-end/parallel/repeat-retry/repeat-phpt-in-main-process.phpt b/tests/end-to-end/parallel/repeat-retry/repeat-phpt-in-main-process.phpt new file mode 100644 index 00000000000..363e73d8366 --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/repeat-phpt-in-main-process.phpt @@ -0,0 +1,37 @@ +--TEST-- +phpunit --repeat 2 --parallel=2 runs the repetitions of a PHPT test sequentially in the main process and skips the remaining repetitions after a failure +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +FS 2 / 2 (100%) + +Time: %s, Memory: %s + +There was 1 failure: + +1) %sRepeatFailingPhpt.phpt (repetition 1 of 2) +Failed asserting that two strings are equal. +--- Expected ++++ Actual +@@ @@ +-'OK' ++'FAIL' + +%sRepeatFailingPhpt.phpt:%d + +FAILURES! +Tests: 2, Assertions: 1, Failures: 1, Skipped: 1. diff --git a/tests/end-to-end/parallel/repeat-retry/retry-in-worker.phpt b/tests/end-to-end/parallel/repeat-retry/retry-in-worker.phpt new file mode 100644 index 00000000000..a0eae5752aa --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/retry-in-worker.phpt @@ -0,0 +1,27 @@ +--TEST-- +phpunit --parallel=2 retries a test method annotated with #[Retry] inside the worker that runs its class +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +There was 1 retried test: + +1) PHPUnit\TestFixture\ParallelRepeatRetry\FlakyTest::testFlaky +1 failed attempt + +OK (2 tests, 2 assertions) diff --git a/tests/end-to-end/parallel/repeat-retry/retry-with-data-provider-in-worker.phpt b/tests/end-to-end/parallel/repeat-retry/retry-with-data-provider-in-worker.phpt new file mode 100644 index 00000000000..d628f5e884c --- /dev/null +++ b/tests/end-to-end/parallel/repeat-retry/retry-with-data-provider-in-worker.phpt @@ -0,0 +1,27 @@ +--TEST-- +phpunit --parallel=2 retries each data set of a test method annotated with #[Retry] individually inside the worker that runs its class +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +There was 1 retried test: + +1) PHPUnit\TestFixture\ParallelRepeatRetry\FlakyWithDataProviderTest::testFlaky#two +2 failed attempts + +OK (2 tests, 2 assertions) diff --git a/tests/end-to-end/parallel/separate-processes/_files/ClassLevelSeparateProcessTest.php b/tests/end-to-end/parallel/separate-processes/_files/ClassLevelSeparateProcessTest.php new file mode 100644 index 00000000000..e8c12a2bdf7 --- /dev/null +++ b/tests/end-to-end/parallel/separate-processes/_files/ClassLevelSeparateProcessTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSeparateProcesses; + +use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; +use PHPUnit\Framework\TestCase; + +#[RunTestsInSeparateProcesses] +final class ClassLevelSeparateProcessTest extends TestCase +{ + public function testClassLevel(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/separate-processes/_files/MethodLevelSeparateProcessTest.php b/tests/end-to-end/parallel/separate-processes/_files/MethodLevelSeparateProcessTest.php new file mode 100644 index 00000000000..b09f82bfe01 --- /dev/null +++ b/tests/end-to-end/parallel/separate-processes/_files/MethodLevelSeparateProcessTest.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSeparateProcesses; + +use PHPUnit\Framework\Attributes\RunInSeparateProcess; +use PHPUnit\Framework\TestCase; + +final class MethodLevelSeparateProcessTest extends TestCase +{ + #[RunInSeparateProcess] + public function testMethodLevel(): void + { + $this->assertTrue(true); + } +} diff --git a/tests/end-to-end/parallel/separate-processes/separate-processes.phpt b/tests/end-to-end/parallel/separate-processes/separate-processes.phpt new file mode 100644 index 00000000000..d849c76799c --- /dev/null +++ b/tests/end-to-end/parallel/separate-processes/separate-processes.phpt @@ -0,0 +1,29 @@ +--TEST-- +phpunit --parallel=2 runs tests requiring process isolation in the main process instead of a worker +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.. 2 / 2 (100%) + +Time: %s, Memory: %s + +Class Level Separate Process (PHPUnit\TestFixture\ParallelSeparateProcesses\ClassLevelSeparateProcess) + ✔ Class level + +Method Level Separate Process (PHPUnit\TestFixture\ParallelSeparateProcesses\MethodLevelSeparateProcess) + ✔ Method level + +OK (2 tests, 2 assertions) diff --git a/tests/end-to-end/parallel/serialization/_files/ClosedResourceDataTest.php b/tests/end-to-end/parallel/serialization/_files/ClosedResourceDataTest.php new file mode 100644 index 00000000000..b04f5c65e91 --- /dev/null +++ b/tests/end-to-end/parallel/serialization/_files/ClosedResourceDataTest.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSerialization; + +use function fclose; +use function fopen; +use function is_resource; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class ClosedResourceDataTest extends TestCase +{ + public static function closedResourceProvider(): array + { + $resource = fopen('php://memory', 'r'); + + fclose($resource); + + return [ + [$resource], + ]; + } + + #[DataProvider('closedResourceProvider')] + public function testReceivesAClosedResource(mixed $value): void + { + $this->assertFalse(is_resource($value)); + } +} diff --git a/tests/end-to-end/parallel/serialization/_files/ClosureDataTest.php b/tests/end-to-end/parallel/serialization/_files/ClosureDataTest.php new file mode 100644 index 00000000000..f52ebde7cda --- /dev/null +++ b/tests/end-to-end/parallel/serialization/_files/ClosureDataTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSerialization; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class ClosureDataTest extends TestCase +{ + public static function closureProvider(): array + { + return [ + [static fn (): bool => true], + ]; + } + + #[DataProvider('closureProvider')] + public function testReceivesAClosure(mixed $value): void + { + $this->assertIsCallable($value); + } +} diff --git a/tests/end-to-end/parallel/serialization/_files/ObjectHoldingAResourceTest.php b/tests/end-to-end/parallel/serialization/_files/ObjectHoldingAResourceTest.php new file mode 100644 index 00000000000..034f399b175 --- /dev/null +++ b/tests/end-to-end/parallel/serialization/_files/ObjectHoldingAResourceTest.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSerialization; + +use function fopen; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use stdClass; + +final class ObjectHoldingAResourceTest extends TestCase +{ + public static function objectProvider(): array + { + $object = new stdClass; + + // A reference back to the object itself exercises the cycle guard that + // keeps the resource walk from recursing forever. + $object->self = $object; + $object->resource = fopen('php://memory', 'r'); + + return [ + [$object], + ]; + } + + #[DataProvider('objectProvider')] + public function testReceivesAnObjectHoldingAResource(object $value): void + { + $this->assertIsResource($value->resource); + } +} diff --git a/tests/end-to-end/parallel/serialization/_files/PlainObjectDataTest.php b/tests/end-to-end/parallel/serialization/_files/PlainObjectDataTest.php new file mode 100644 index 00000000000..a00cbd1100c --- /dev/null +++ b/tests/end-to-end/parallel/serialization/_files/PlainObjectDataTest.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelSerialization; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use stdClass; + +final class PlainObjectDataTest extends TestCase +{ + public static function objectProvider(): array + { + $object = new stdClass; + + $object->value = 'plain'; + + return [ + [$object], + ]; + } + + #[DataProvider('objectProvider')] + public function testReceivesAPlainObject(object $value): void + { + $this->assertSame('plain', $value->value); + } +} diff --git a/tests/end-to-end/parallel/serialization/serialization.phpt b/tests/end-to-end/parallel/serialization/serialization.phpt new file mode 100644 index 00000000000..833f01e9747 --- /dev/null +++ b/tests/end-to-end/parallel/serialization/serialization.phpt @@ -0,0 +1,22 @@ +--TEST-- +phpunit --parallel=2 runs tests whose data cannot be transported to a worker in the main process and the rest in workers +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +.... 4 / 4 (100%) + +Time: %s, Memory: %s + +OK (4 tests, 4 assertions) diff --git a/tests/end-to-end/parallel/worker-id/_files/WorkerIdentityTest.php b/tests/end-to-end/parallel/worker-id/_files/WorkerIdentityTest.php new file mode 100644 index 00000000000..426d09fafa5 --- /dev/null +++ b/tests/end-to-end/parallel/worker-id/_files/WorkerIdentityTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TestFixture\ParallelWorkerIdentity; + +use function getenv; +use function strlen; +use function substr; +use PHPUnit\Framework\TestCase; + +final class WorkerIdentityTest extends TestCase +{ + public function testWorkerIdentityIsExposedToTheTest(): void + { + $id = getenv('PHPUNIT_WORKER_ID'); + $token = getenv('PHPUNIT_WORKER_TOKEN'); + + $this->assertIsString($id); + $this->assertMatchesRegularExpression('/^\d+$/', $id); + + $this->assertIsString($token); + $this->assertMatchesRegularExpression('/^\d+_[0-9a-f]{32}$/', $token); + + $this->assertSame($id . '_', substr($token, 0, strlen($id) + 1)); + } +} diff --git a/tests/end-to-end/parallel/worker-id/worker-id.phpt b/tests/end-to-end/parallel/worker-id/worker-id.phpt new file mode 100644 index 00000000000..1fdf5ad7f6e --- /dev/null +++ b/tests/end-to-end/parallel/worker-id/worker-id.phpt @@ -0,0 +1,22 @@ +--TEST-- +A worker exposes its identity to the tests it runs through the PHPUNIT_WORKER_ID and PHPUNIT_WORKER_TOKEN environment variables +--FILE-- +run($_SERVER['argv']); +--EXPECTF-- +PHPUnit %s by Sebastian Bergmann and contributors. + +Runtime: %s + +. 1 / 1 (100%) + +Time: %s, Memory: %s + +OK (1 test, 5 assertions) diff --git a/tests/end-to-end/retry/retry-phpt-cli-option.phpt b/tests/end-to-end/retry/retry-phpt-cli-option.phpt index 85ada5a8d93..b2344f17d66 100644 --- a/tests/end-to-end/retry/retry-phpt-cli-option.phpt +++ b/tests/end-to-end/retry/retry-phpt-cli-option.phpt @@ -1,5 +1,7 @@ --TEST-- The --retry CLI option attempts a PHPT test up to N times, stopping at the first success +--CONFLICTS-- +all --FILE-- assertTrue($collection->asArray()[0]->isDoesNotPerformAssertions()); } + public function test_Can_be_filtered_for_DoNotRunInParallel(): void + { + $collection = $this->collectionWithOneOfEach()->isDoNotRunInParallel(); + + $this->assertCount(1, $collection); + $this->assertTrue($collection->asArray()[0]->isDoNotRunInParallel()); + } + public function test_Can_be_filtered_for_ExcludeGlobalVariableFromBackup(): void { $collection = $this->collectionWithOneOfEach()->isExcludeGlobalVariableFromBackup(); @@ -625,6 +634,7 @@ private function collectionWithOneOfEach(): MetadataCollection Metadata::dependsOnMethod('', '', false, false), Metadata::disableReturnValueGenerationForTestDoubles(), Metadata::doesNotPerformAssertionsOnClass(), + Metadata::doNotRunInParallelOnClass(), Metadata::excludeGlobalVariableFromBackupOnClass(''), Metadata::excludeStaticPropertyFromBackupOnClass('', ''), Metadata::groupOnClass(''), diff --git a/tests/unit/Metadata/MetadataTest.php b/tests/unit/Metadata/MetadataTest.php index 5b0f6620742..15857103f56 100644 --- a/tests/unit/Metadata/MetadataTest.php +++ b/tests/unit/Metadata/MetadataTest.php @@ -51,6 +51,7 @@ public function testCanBeAfter(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -121,6 +122,7 @@ public function testCanBeAfterClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -189,6 +191,7 @@ public function testCanBeAllowMockObjectsWithoutExpectations(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -256,6 +259,7 @@ public function testCanBeBackupGlobalsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -324,6 +328,7 @@ public function testCanBeBackupGlobalsOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -392,6 +397,7 @@ public function testCanBeBackupStaticPropertiesOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -460,6 +466,7 @@ public function testCanBeBackupStaticPropertiesOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -530,6 +537,7 @@ public function testCanBeBeforeClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -599,6 +607,7 @@ public function testCanBeBefore(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -667,6 +676,7 @@ public function testCanBeCoversClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -738,6 +748,7 @@ public function testCanBeCoversNamespace(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -807,6 +818,7 @@ public function testCanBeCoversClassesThatExtendClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -876,6 +888,7 @@ public function testCanBeCoversClassesThatImplementInterface(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -945,6 +958,7 @@ public function testCanBeCoversFunction(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1014,6 +1028,7 @@ public function testCanBeCoversMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1084,6 +1099,7 @@ public function testCanBeCoversNothingOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1151,6 +1167,7 @@ public function testCanBeCoversNothingOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1218,6 +1235,7 @@ public function testCanBeCoversTrait(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1287,6 +1305,7 @@ public function testCanBeDataProvider(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1362,6 +1381,7 @@ public function testCanBeDataProviderClosure(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1432,6 +1452,7 @@ public function testCanBeDependsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1503,6 +1524,7 @@ public function testCanBeDependsOnMethod(): void $this->assertTrue($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1575,6 +1597,7 @@ public function testCanBeDisableReturnValueGenerationForTestDoubles(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertTrue($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1642,6 +1665,7 @@ public function testCanBeDoesNotPerformAssertionsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertTrue($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1709,6 +1733,143 @@ public function testCanBeDoesNotPerformAssertionsOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertTrue($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); + $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); + $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); + $this->assertFalse($metadata->isGroup()); + $this->assertFalse($metadata->isIgnoreDeprecations()); + $this->assertFalse($metadata->isIgnorePhpunitDeprecations()); + $this->assertFalse($metadata->isIgnorePHPUnitWarnings()); + $this->assertFalse($metadata->isInvalidAttribute()); + $this->assertFalse($metadata->isRepeat()); + $this->assertFalse($metadata->isRetry()); + $this->assertFalse($metadata->isRunInSeparateProcess()); + $this->assertFalse($metadata->isRunTestsInSeparateProcesses()); + $this->assertFalse($metadata->isTest()); + $this->assertFalse($metadata->isPreCondition()); + $this->assertFalse($metadata->isPostCondition()); + $this->assertFalse($metadata->isPreserveGlobalState()); + $this->assertFalse($metadata->isRequiresMethod()); + $this->assertFalse($metadata->isRequiresFunction()); + $this->assertFalse($metadata->isRequiresOperatingSystem()); + $this->assertFalse($metadata->isRequiresOperatingSystemFamily()); + $this->assertFalse($metadata->isRequiresPhp()); + $this->assertFalse($metadata->isRequiresPhpExtension()); + $this->assertFalse($metadata->isRequiresPhpunit()); + $this->assertFalse($metadata->isRequiresPhpunitExtension()); + $this->assertFalse($metadata->isRequiresEnvironmentVariable()); + $this->assertFalse($metadata->isWithEnvironmentVariable()); + $this->assertFalse($metadata->isRequiresSetting()); + $this->assertFalse($metadata->isTestDox()); + $this->assertFalse($metadata->isTestDoxFormatter()); + $this->assertFalse($metadata->isTestWith()); + $this->assertFalse($metadata->isUsesNamespace()); + $this->assertFalse($metadata->isUsesClass()); + $this->assertFalse($metadata->isUsesClassesThatExtendClass()); + $this->assertFalse($metadata->isUsesClassesThatImplementInterface()); + $this->assertFalse($metadata->isUsesFunction()); + $this->assertFalse($metadata->isUsesMethod()); + $this->assertFalse($metadata->isUsesTrait()); + $this->assertFalse($metadata->isWithoutErrorHandler()); + + $this->assertTrue($metadata->isMethodLevel()); + $this->assertFalse($metadata->isClassLevel()); + } + + public function testCanBeDoNotRunInParallelOnClass(): void + { + $metadata = Metadata::doNotRunInParallelOnClass(); + + $this->assertFalse($metadata->isAfter()); + $this->assertFalse($metadata->isAfterClass()); + $this->assertFalse($metadata->isAllowMockObjectsWithoutExpectations()); + $this->assertFalse($metadata->isBackupGlobals()); + $this->assertFalse($metadata->isBackupStaticProperties()); + $this->assertFalse($metadata->isBeforeClass()); + $this->assertFalse($metadata->isBefore()); + $this->assertFalse($metadata->isCoversNamespace()); + $this->assertFalse($metadata->isCoversClass()); + $this->assertFalse($metadata->isCoversClassesThatExtendClass()); + $this->assertFalse($metadata->isCoversClassesThatImplementInterface()); + $this->assertFalse($metadata->isCoversFunction()); + $this->assertFalse($metadata->isCoversMethod()); + $this->assertFalse($metadata->isCoversNothing()); + $this->assertFalse($metadata->isCoversTrait()); + $this->assertFalse($metadata->isDataProvider()); + $this->assertFalse($metadata->isDataProviderClosure()); + $this->assertFalse($metadata->isDependsOnClass()); + $this->assertFalse($metadata->isDependsOnMethod()); + $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); + $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertTrue($metadata->isDoNotRunInParallel()); + $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); + $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); + $this->assertFalse($metadata->isGroup()); + $this->assertFalse($metadata->isIgnoreDeprecations()); + $this->assertFalse($metadata->isIgnorePhpunitDeprecations()); + $this->assertFalse($metadata->isIgnorePHPUnitWarnings()); + $this->assertFalse($metadata->isInvalidAttribute()); + $this->assertFalse($metadata->isRepeat()); + $this->assertFalse($metadata->isRetry()); + $this->assertFalse($metadata->isRunInSeparateProcess()); + $this->assertFalse($metadata->isRunTestsInSeparateProcesses()); + $this->assertFalse($metadata->isTest()); + $this->assertFalse($metadata->isPreCondition()); + $this->assertFalse($metadata->isPostCondition()); + $this->assertFalse($metadata->isPreserveGlobalState()); + $this->assertFalse($metadata->isRequiresMethod()); + $this->assertFalse($metadata->isRequiresFunction()); + $this->assertFalse($metadata->isRequiresOperatingSystem()); + $this->assertFalse($metadata->isRequiresOperatingSystemFamily()); + $this->assertFalse($metadata->isRequiresPhp()); + $this->assertFalse($metadata->isRequiresPhpExtension()); + $this->assertFalse($metadata->isRequiresPhpunit()); + $this->assertFalse($metadata->isRequiresPhpunitExtension()); + $this->assertFalse($metadata->isRequiresEnvironmentVariable()); + $this->assertFalse($metadata->isWithEnvironmentVariable()); + $this->assertFalse($metadata->isRequiresSetting()); + $this->assertFalse($metadata->isTestDox()); + $this->assertFalse($metadata->isTestDoxFormatter()); + $this->assertFalse($metadata->isTestWith()); + $this->assertFalse($metadata->isUsesNamespace()); + $this->assertFalse($metadata->isUsesClass()); + $this->assertFalse($metadata->isUsesClassesThatExtendClass()); + $this->assertFalse($metadata->isUsesClassesThatImplementInterface()); + $this->assertFalse($metadata->isUsesFunction()); + $this->assertFalse($metadata->isUsesMethod()); + $this->assertFalse($metadata->isUsesTrait()); + $this->assertFalse($metadata->isWithoutErrorHandler()); + + $this->assertTrue($metadata->isClassLevel()); + $this->assertFalse($metadata->isMethodLevel()); + } + + public function testCanBeDoNotRunInParallelOnMethod(): void + { + $metadata = Metadata::doNotRunInParallelOnMethod(); + + $this->assertFalse($metadata->isAfter()); + $this->assertFalse($metadata->isAfterClass()); + $this->assertFalse($metadata->isAllowMockObjectsWithoutExpectations()); + $this->assertFalse($metadata->isBackupGlobals()); + $this->assertFalse($metadata->isBackupStaticProperties()); + $this->assertFalse($metadata->isBeforeClass()); + $this->assertFalse($metadata->isBefore()); + $this->assertFalse($metadata->isCoversNamespace()); + $this->assertFalse($metadata->isCoversClass()); + $this->assertFalse($metadata->isCoversClassesThatExtendClass()); + $this->assertFalse($metadata->isCoversClassesThatImplementInterface()); + $this->assertFalse($metadata->isCoversFunction()); + $this->assertFalse($metadata->isCoversMethod()); + $this->assertFalse($metadata->isCoversNothing()); + $this->assertFalse($metadata->isCoversTrait()); + $this->assertFalse($metadata->isDataProvider()); + $this->assertFalse($metadata->isDataProviderClosure()); + $this->assertFalse($metadata->isDependsOnClass()); + $this->assertFalse($metadata->isDependsOnMethod()); + $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); + $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertTrue($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1776,6 +1937,7 @@ public function testCanBeExcludeGlobalVariableFromBackupOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertTrue($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1844,6 +2006,7 @@ public function testCanBeExcludeGlobalVariableFromBackupOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertTrue($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1912,6 +2075,7 @@ public function testCanBeExcludeStaticPropertyFromBackupOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertTrue($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -1982,6 +2146,7 @@ public function testCanBeExcludeStaticPropertyFromBackupOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertTrue($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2052,6 +2217,7 @@ public function testCanBeGroupOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertTrue($metadata->isGroup()); @@ -2123,6 +2289,7 @@ public function testCanBeIgnoreDeprecationsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2191,6 +2358,7 @@ public function testCanBeIgnoreDeprecationsOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2258,6 +2426,7 @@ public function testCanBeIgnorePhpunitDeprecationsOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2325,6 +2494,7 @@ public function testCanBeIgnorePhpunitDeprecationsOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2394,6 +2564,7 @@ public function testCanBeIgnorePHPUnitWarnings(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2460,6 +2631,7 @@ public function testCanBeInvalidAttributeOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2526,6 +2698,7 @@ public function testCanBeInvalidAttributeOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2592,6 +2765,7 @@ public function testCanBeGroupOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertTrue($metadata->isGroup()); @@ -2661,6 +2835,7 @@ public function testCanBeRunTestsInSeparateProcesses(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2728,6 +2903,7 @@ public function testCanBeRunInSeparateProcess(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2793,6 +2969,7 @@ public function testCanBeTest(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2862,6 +3039,7 @@ public function testCanBePreCondition(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2932,6 +3110,7 @@ public function testCanBePostCondition(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -2996,6 +3175,7 @@ public function testCanBePreserveGlobalStateOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3061,6 +3241,7 @@ public function testCanBePreserveGlobalStateOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3130,6 +3311,7 @@ public function testCanBeRequiresMethodOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3200,6 +3382,7 @@ public function testCanBeRequiresMethodOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3270,6 +3453,7 @@ public function testCanBeRequiresFunctionOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3339,6 +3523,7 @@ public function testCanBeRequiresFunctionOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3408,6 +3593,7 @@ public function testCanBeRequiresOperatingSystemOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3477,6 +3663,7 @@ public function testCanBeRequiresOperatingSystemOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3546,6 +3733,7 @@ public function testCanBeRequiresOperatingSystemFamilyOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3615,6 +3803,7 @@ public function testCanBeRequiresOperatingSystemFamilyOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3689,6 +3878,7 @@ public function testCanBeRequiresPhpOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3763,6 +3953,7 @@ public function testCanBeRequiresPhpOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3832,6 +4023,7 @@ public function testCanBeRequiresPhpExtensionOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3911,6 +4103,7 @@ public function testCanBeRequiresPhpExtensionWithVersionOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -3982,6 +4175,7 @@ public function testCanBeRequiresPhpExtensionOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4061,6 +4255,7 @@ public function testCanBeRequiresPhpExtensionWithVersionOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4137,6 +4332,7 @@ public function testCanBeRequiresPhpunitOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4206,6 +4402,7 @@ public function testCanBeRequiresPhpunitExtensionOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4280,6 +4477,7 @@ public function testCanBeRequiresPhpunitOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4349,6 +4547,7 @@ public function testCanBeRequiresPhpunitExtensionOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4418,6 +4617,7 @@ public function testCanBeRequiresEnvironmentVariableOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4488,6 +4688,7 @@ public function testCanBeRequiresEnvironmentVariableOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4558,6 +4759,7 @@ public function testCanBeWithEnvironmentVariableOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4628,6 +4830,7 @@ public function testCanBeWithEnvironmentVariableOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4698,6 +4901,7 @@ public function testCanBeRequiresSettingOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4768,6 +4972,7 @@ public function testCanBeRequiresSettingOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4838,6 +5043,7 @@ public function testCanBeTestDoxOnClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4907,6 +5113,7 @@ public function testCanBeTestDoxOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -4976,6 +5183,7 @@ public function testCanBeTestDoxFormatterOnMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5046,6 +5254,7 @@ public function testCanBeTestWith(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5118,6 +5327,7 @@ public function testCanBeUsesNamespace(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5187,6 +5397,7 @@ public function testCanBeUsesClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5256,6 +5467,7 @@ public function testCanBeUsesClassesThatExtendClass(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5325,6 +5537,7 @@ public function testCanBeUsesClassesThatImplementInterface(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5394,6 +5607,7 @@ public function testCanBeUsesFunction(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5462,6 +5676,7 @@ public function testCanBeUsesMethod(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5532,6 +5747,7 @@ public function testCanBeUsesTrait(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5601,6 +5817,7 @@ public function testCanBeWithoutErrorHandler(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5668,6 +5885,7 @@ public function testCanBeRepeat(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); @@ -5737,6 +5955,7 @@ public function testCanBeRetry(): void $this->assertFalse($metadata->isDependsOnMethod()); $this->assertFalse($metadata->isDisableReturnValueGenerationForTestDoubles()); $this->assertFalse($metadata->isDoesNotPerformAssertions()); + $this->assertFalse($metadata->isDoNotRunInParallel()); $this->assertFalse($metadata->isExcludeGlobalVariableFromBackup()); $this->assertFalse($metadata->isExcludeStaticPropertyFromBackup()); $this->assertFalse($metadata->isGroup()); diff --git a/tests/unit/Metadata/Parser/AttributeParserTest.php b/tests/unit/Metadata/Parser/AttributeParserTest.php index b1592b0714d..5529abba24e 100644 --- a/tests/unit/Metadata/Parser/AttributeParserTest.php +++ b/tests/unit/Metadata/Parser/AttributeParserTest.php @@ -36,6 +36,7 @@ use PHPUnit\Framework\Attributes\DependsUsingDeepClone; use PHPUnit\Framework\Attributes\DependsUsingShallowClone; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; +use PHPUnit\Framework\Attributes\DoNotRunInParallel; use PHPUnit\Framework\Attributes\ExcludeGlobalVariableFromBackup; use PHPUnit\Framework\Attributes\ExcludeStaticPropertyFromBackup; use PHPUnit\Framework\Attributes\Group; @@ -106,6 +107,7 @@ #[CoversClass(DependsUsingShallowClone::class)] #[CoversClass(DisableReturnValueGenerationForTestDoubles::class)] #[CoversClass(DoesNotPerformAssertions::class)] +#[CoversClass(DoNotRunInParallel::class)] #[CoversClass(ExcludeGlobalVariableFromBackup::class)] #[CoversClass(ExcludeStaticPropertyFromBackup::class)] #[CoversClass(Group::class)] diff --git a/tests/unit/Metadata/Parser/AttributeParserTestCase.php b/tests/unit/Metadata/Parser/AttributeParserTestCase.php index d6bbeacd24b..66c0415267e 100644 --- a/tests/unit/Metadata/Parser/AttributeParserTestCase.php +++ b/tests/unit/Metadata/Parser/AttributeParserTestCase.php @@ -38,6 +38,7 @@ use PHPUnit\TestFixture\Metadata\Attribute\DependencyTest; use PHPUnit\TestFixture\Metadata\Attribute\DisableReturnValueGenerationForTestDoublesTest; use PHPUnit\TestFixture\Metadata\Attribute\DoesNotPerformAssertionsTest; +use PHPUnit\TestFixture\Metadata\Attribute\DoNotRunInParallelTest; use PHPUnit\TestFixture\Metadata\Attribute\DuplicateSmallAttributeTest; use PHPUnit\TestFixture\Metadata\Attribute\DuplicateTestAttributeTest; use PHPUnit\TestFixture\Metadata\Attribute\Example; @@ -202,6 +203,15 @@ public function test_parses_DoesNotPerformAssertions_attribute_on_class(): void $this->assertTrue($metadata->asArray()[0]->isDoesNotPerformAssertions()); } + #[TestDox('Parses #[DoNotRunInParallel] attribute on class')] + public function test_parses_DoNotRunInParallel_attribute_on_class(): void + { + $metadata = $this->parser()->forClass(DoNotRunInParallelTest::class)->isDoNotRunInParallel(); + + $this->assertCount(1, $metadata); + $this->assertTrue($metadata->asArray()[0]->isDoNotRunInParallel()); + } + #[TestDox('Parses #[ExcludeGlobalVariableFromBackup] attribute on class')] public function test_parses_ExcludeGlobalVariableFromBackup_attribute_on_class(): void { @@ -814,6 +824,15 @@ public function test_parses_DoesNotPerformAssertions_attribute_on_method(): void $this->assertTrue($metadata->asArray()[0]->isDoesNotPerformAssertions()); } + #[TestDox('Parses #[DoNotRunInParallel] attribute on method')] + public function test_parses_DoNotRunInParallel_attribute_on_method(): void + { + $metadata = $this->parser()->forMethod(DoNotRunInParallelTest::class, 'testOne')->isDoNotRunInParallel(); + + $this->assertCount(1, $metadata); + $this->assertTrue($metadata->asArray()[0]->isDoNotRunInParallel()); + } + #[TestDox('Parses #[ExcludeGlobalVariableFromBackup] attribute on method')] public function test_parses_ExcludeGlobalVariableFromBackup_attribute_on_method(): void { diff --git a/tests/unit/Runner/Parallel/PersistentWorkerTest.php b/tests/unit/Runner/Parallel/PersistentWorkerTest.php new file mode 100644 index 00000000000..f83f16383ed --- /dev/null +++ b/tests/unit/Runner/Parallel/PersistentWorkerTest.php @@ -0,0 +1,170 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function strlen; +use function substr; +use function unserialize; +use function usleep; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade; +use PHPUnit\Event\Test\Errored; +use PHPUnit\Event\Test\Failed; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Large; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestFixture\ParallelWorker\WorkerFirstTest; +use PHPUnit\TestFixture\ParallelWorker\WorkerSecondTest; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; + +#[CoversClass(PersistentWorker::class)] +#[UsesClass(TestClassWorkUnit::class)] +#[UsesClass(CompletedWorkUnit::class)] +#[UsesClass(JobRunner::class)] +#[UsesClass(Job::class)] +#[Large] +final class PersistentWorkerTest extends TestCase +{ + public function testReusesOneProcessAcrossSequentiallyDispatchedUnits(): void + { + $worker = $this->worker(); + + $worker->start(); + + $first = $this->runToCompletion( + $worker, + new TestClassWorkUnit(0, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + ); + + // The second unit only passes if it ran in the same process as the + // first one, which is what reusing a single worker provides. + $second = $this->runToCompletion( + $worker, + new TestClassWorkUnit(1, WorkerSecondTest::class, [new WorkerSecondTest('testSeesTheStateLeftBehindByTheFirstTest')]), + ); + + $worker->stop(); + + $this->assertFalse($first->crashed()); + $this->assertFalse($this->failedOrErrored($first)); + + $this->assertFalse($second->crashed()); + $this->assertFalse($this->failedOrErrored($second)); + } + + public function testReportsAFailingTestThroughTheResultEnvelopeRatherThanAsACrash(): void + { + $worker = $this->worker(); + + $worker->start(); + + $completed = $this->runToCompletion( + $worker, + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatFails')]), + ); + + $worker->stop(); + + $this->assertFalse($completed->crashed()); + $this->assertTrue($this->failedOrErrored($completed)); + } + + public function testRecognizesCompletionEvenWhenATestLeavesStrayOutputOnTheControlChannel(): void + { + $worker = $this->worker(); + + $worker->start(); + + // The test writes stray bytes to file descriptor 1 without a trailing + // newline; the worker must still report the unit as finished and ship a + // result envelope that is not corrupted by that output. + $completed = $this->runToCompletion( + $worker, + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatWritesStrayOutputWithoutANewlineToTheControlChannel')]), + ); + + $worker->stop(); + + $this->assertFalse($completed->crashed()); + $this->assertFalse($this->failedOrErrored($completed)); + } + + public function testReportsACrashWhenTheWorkerDiesWhileRunningAUnit(): void + { + $worker = $this->worker(); + + $worker->start(); + + $completed = $this->runToCompletion( + $worker, + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatKillsTheWorkerProcess')]), + ); + + $worker->stop(); + + $this->assertTrue($completed->crashed()); + } + + private function runToCompletion(PersistentWorker $worker, TestClassWorkUnit $unit): CompletedWorkUnit + { + $worker->dispatch($unit); + + while (true) { + $completed = $worker->poll(); + + if ($completed !== null) { + return $completed; + } + + usleep(1000); + } + } + + private function failedOrErrored(CompletedWorkUnit $completed): bool + { + foreach ($this->eventsOf($completed) as $event) { + if ($event instanceof Failed || $event instanceof Errored) { + return true; + } + } + + return false; + } + + private function eventsOf(CompletedWorkUnit $completed): EventCollection + { + $nonce = (string) $completed->nonce(); + $serialized = substr($completed->serializedResult(), strlen($nonce)); + $envelope = unserialize($serialized); + + $this->assertIsObject($envelope); + $this->assertInstanceOf(EventCollection::class, $envelope->events); + + return $envelope->events; + } + + private function worker(): PersistentWorker + { + $processor = new ChildProcessResultProcessor( + new Facade, + $this->createStub(Emitter::class), + new PassedTests, + new CodeCoverage, + ); + + return new PersistentWorker(new JobRunner($processor)); + } +} diff --git a/tests/unit/Runner/Parallel/PhptRunnerTest.php b/tests/unit/Runner/Parallel/PhptRunnerTest.php new file mode 100644 index 00000000000..b105e30c445 --- /dev/null +++ b/tests/unit/Runner/Parallel/PhptRunnerTest.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function array_keys; +use function ksort; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade; +use PHPUnit\Event\Test\Passed; +use PHPUnit\Event\Test\Skipped; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Large; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; + +#[CoversClass(PhptRunner::class)] +#[CoversClass(PhptWorkUnit::class)] +#[UsesClass(JobRunner::class)] +#[UsesClass(Job::class)] +#[Large] +final class PhptRunnerTest extends TestCase +{ + public function testRunsPhptTestsConcurrentlyAndReportsEachWithItsCollectedEvents(): void + { + $file = __DIR__ . '/../../../_files/parallel-worker/worker.phpt'; + + $units = [ + new PhptWorkUnit(0, $file), + new PhptWorkUnit(1, $file), + ]; + + $collected = $this->execute($units, 2); + + $this->assertSame([0, 1], array_keys($collected)); + + foreach ($collected as $events) { + $this->assertTrue($this->contains($events, Passed::class)); + } + } + + public function testRunsAPhptTestWhoseSectionsEachNeedTheirOwnChildProcess(): void + { + // The --INI-- section forces the --CLEAN-- section to run in a child + // process of its own, so this test's generator yields a second job + // after the --FILE-- job and exercises the runner's handling of a unit + // that is not finished by its first child process. + $units = [ + new PhptWorkUnit(0, __DIR__ . '/../../../_files/parallel-worker/worker-with-clean.phpt'), + ]; + + $collected = $this->execute($units, 2); + + $this->assertSame([0], array_keys($collected)); + $this->assertTrue($this->contains($collected[0], Passed::class)); + } + + public function testReportsAPhptTestThatNeedsNoChildProcessAtAll(): void + { + // This test is skipped by a --SKIPIF-- section that runs in-process, so + // its generator produces its events without ever yielding a job; the + // runner must report it just the same. + $units = [ + new PhptWorkUnit(0, __DIR__ . '/../../../_files/parallel-worker/worker-skipped.phpt'), + ]; + + $collected = $this->execute($units, 2); + + $this->assertSame([0], array_keys($collected)); + $this->assertTrue($this->contains($collected[0], Skipped::class)); + } + + public function testRunsPhptTestsOneAtATimeWhenConcurrencyIsOne(): void + { + $file = __DIR__ . '/../../../_files/parallel-worker/worker.phpt'; + + $units = [ + new PhptWorkUnit(0, $file), + new PhptWorkUnit(1, $file), + new PhptWorkUnit(2, $file), + ]; + + $collected = $this->execute($units, 1); + + $this->assertSame([0, 1, 2], array_keys($collected)); + + foreach ($collected as $events) { + $this->assertTrue($this->contains($events, Passed::class)); + } + } + + /** + * @param list $units + * @param positive-int $concurrency + * + * @return array + */ + private function execute(array $units, int $concurrency): array + { + $processor = new ChildProcessResultProcessor( + Facade::instance(), + $this->createStub(Emitter::class), + new PassedTests, + new CodeCoverage, + ); + + $collected = []; + + new PhptRunner(new JobRunner($processor), $concurrency)->run( + $units, + static function (int $index, EventCollection $events) use (&$collected): void + { + $collected[$index] = $events; + }, + ); + + ksort($collected); + + return $collected; + } + + /** + * @param class-string $eventClass + */ + private function contains(EventCollection $events, string $eventClass): bool + { + foreach ($events as $event) { + if ($event instanceof $eventClass) { + return true; + } + } + + return false; + } +} diff --git a/tests/unit/Runner/Parallel/PhptWorkUnitTest.php b/tests/unit/Runner/Parallel/PhptWorkUnitTest.php new file mode 100644 index 00000000000..59744804707 --- /dev/null +++ b/tests/unit/Runner/Parallel/PhptWorkUnitTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; + +#[CoversClass(PhptWorkUnit::class)] +#[Small] +final class PhptWorkUnitTest extends TestCase +{ + public function testHasIndex(): void + { + $this->assertSame(5, $this->unit()->index()); + } + + public function testHasFile(): void + { + $this->assertSame('/path/to/test.phpt', $this->unit()->file()); + } + + public function testIsNamedAfterItsFile(): void + { + $this->assertSame('/path/to/test.phpt', $this->unit()->name()); + } + + public function testHasNoConflictsByDefault(): void + { + $this->assertSame([], $this->unit()->conflicts()); + } + + public function testHasConflicts(): void + { + $unit = new PhptWorkUnit(5, '/path/to/test.phpt', ['all']); + + $this->assertSame(['all'], $unit->conflicts()); + } + + private function unit(): PhptWorkUnit + { + return new PhptWorkUnit(5, '/path/to/test.phpt'); + } +} diff --git a/tests/unit/Runner/Parallel/ResultAggregatorTest.php b/tests/unit/Runner/Parallel/ResultAggregatorTest.php new file mode 100644 index 00000000000..ea0fa3b7169 --- /dev/null +++ b/tests/unit/Runner/Parallel/ResultAggregatorTest.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function serialize; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\EventCollection; +use PHPUnit\Event\Facade; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestFixture\ParallelWorker\WorkerFirstTest; +use PHPUnit\TestFixture\ParallelWorker\WorkerSecondTest; +use PHPUnit\TestRunner\TestResult\PassedTests; + +#[CoversClass(ResultAggregator::class)] +#[UsesClass(TestClassWorkUnit::class)] +#[UsesClass(CompletedWorkUnit::class)] +#[Small] +final class ResultAggregatorTest extends TestCase +{ + public function testForwardsAndImportsAFinishedUnit(): void + { + $emitter = $this->createMock(Emitter::class); + + $emitter->expects($this->never())->method('childProcessErrored'); + $emitter->expects($this->never())->method('testRunnerTriggeredPhpunitWarning'); + + $aggregator = $this->aggregator($emitter); + + $nonce = 'abc'; + + $aggregator->add( + new CompletedWorkUnit( + new TestClassWorkUnit(0, self::class, []), + $nonce . serialize((object) ['codeCoverage' => null, 'events' => new EventCollection, 'passedTests' => new PassedTests]), + $nonce, + false, + ), + ); + } + + public function testReportsACrashedUnitAsAWarning(): void + { + $emitter = $this->createMock(Emitter::class); + + $emitter->expects($this->once())->method('childProcessErrored'); + $emitter->expects($this->once()) + ->method('testRunnerTriggeredPhpunitWarning') + ->with($this->stringContains('ended unexpectedly')); + + $this->aggregator($emitter)->add( + new CompletedWorkUnit(new TestClassWorkUnit(0, self::class, []), '', null, true), + ); + } + + public function testRejectsAResultWhoseNonceDoesNotMatch(): void + { + $emitter = $this->createMock(Emitter::class); + + $emitter->expects($this->once())->method('childProcessErrored'); + $emitter->expects($this->once()) + ->method('testRunnerTriggeredPhpunitWarning') + ->with($this->stringContains('tampered with')); + + $this->aggregator($emitter)->add( + new CompletedWorkUnit(new TestClassWorkUnit(0, self::class, []), 'expected-nonce' . serialize((object) []), 'actual-nonce', false), + ); + } + + public function testRejectsAMalformedResult(): void + { + $emitter = $this->createMock(Emitter::class); + + $emitter->expects($this->once())->method('childProcessErrored'); + $emitter->expects($this->once()) + ->method('testRunnerTriggeredPhpunitWarning') + ->with($this->stringContains('ended unexpectedly')); + + $nonce = 'abc'; + + $this->aggregator($emitter)->add( + new CompletedWorkUnit(new TestClassWorkUnit(0, self::class, []), $nonce . 'not-a-serialized-envelope', $nonce, false), + ); + } + + public function testForwardsBufferedUnitsInSuiteOrderRegardlessOfCompletionOrder(): void + { + $messages = []; + + $emitter = $this->createMock(Emitter::class); + + $emitter->method('testRunnerTriggeredPhpunitWarning')->willReturnCallback( + static function (string $message) use (&$messages): void + { + $messages[] = $message; + }, + ); + + $aggregator = $this->aggregator($emitter); + + // The unit at index 1 finishes first; it must not be forwarded until + // the unit at index 0, which precedes it in suite order, has been + // forwarded. + $aggregator->add(new CompletedWorkUnit(new TestClassWorkUnit(1, WorkerSecondTest::class, []), '', null, true)); + + $this->assertSame([], $messages); + + $aggregator->add(new CompletedWorkUnit(new TestClassWorkUnit(0, WorkerFirstTest::class, []), '', null, true)); + + $this->assertCount(2, $messages); + $this->assertStringContainsString(WorkerFirstTest::class, $messages[0]); + $this->assertStringContainsString(WorkerSecondTest::class, $messages[1]); + } + + public function testRunsInProcessUnitsAtTheirIndexInGlobalSuiteOrder(): void + { + $order = []; + + $emitter = $this->createMock(Emitter::class); + + $emitter->method('testRunnerTriggeredPhpunitWarning')->willReturnCallback( + static function (string $message) use (&$order): void + { + $order[] = $message; + }, + ); + + $aggregator = $this->aggregator($emitter); + + // The unit at index 1 runs in the main process; the units at index 0 + // and 2 run in workers and finish out of order. + $aggregator->registerInProcessUnit( + 1, + static function () use (&$order): void + { + $order[] = 'in-process'; + }, + ); + + // The worker unit at index 2 finishes first and must be held back. + $aggregator->add(new CompletedWorkUnit(new TestClassWorkUnit(2, WorkerSecondTest::class, []), '', null, true)); + + $this->assertSame([], $order); + + // Once index 0 arrives, index 0 is forwarded, then the in-process unit + // at index 1 is run in place, then index 2 is released — global order. + $aggregator->add(new CompletedWorkUnit(new TestClassWorkUnit(0, WorkerFirstTest::class, []), '', null, true)); + + $this->assertCount(3, $order); + $this->assertStringContainsString(WorkerFirstTest::class, $order[0]); + $this->assertSame('in-process', $order[1]); + $this->assertStringContainsString(WorkerSecondTest::class, $order[2]); + } + + public function testRunsRegisteredInProcessUnitsThatPrecedeAllWorkerUnitsOnFlush(): void + { + $order = []; + + $aggregator = $this->aggregator($this->createStub(Emitter::class)); + + $aggregator->registerInProcessUnit( + 0, + static function () use (&$order): void + { + $order[] = 'in-process'; + }, + ); + + // No worker completion drives the release, so an explicit flush() must + // run the leading in-process unit. + $this->assertSame([], $order); + + $aggregator->flush(); + + $this->assertSame(['in-process'], $order); + } + + private function aggregator(Emitter $emitter): ResultAggregator + { + return new ResultAggregator( + new Facade, + $emitter, + new PassedTests, + new CodeCoverage, + ); + } +} diff --git a/tests/unit/Runner/Parallel/TestClassWorkUnitTest.php b/tests/unit/Runner/Parallel/TestClassWorkUnitTest.php new file mode 100644 index 00000000000..fe65541a31d --- /dev/null +++ b/tests/unit/Runner/Parallel/TestClassWorkUnitTest.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use PHPUnit\TestFixture\ParallelWorker\WorkerFirstTest; + +#[CoversClass(TestClassWorkUnit::class)] +#[Small] +final class TestClassWorkUnitTest extends TestCase +{ + public function testHasIndex(): void + { + $this->assertSame(3, $this->unit()->index()); + } + + public function testHasClassName(): void + { + $this->assertSame(WorkerFirstTest::class, $this->unit()->className()); + } + + public function testHasTests(): void + { + $tests = $this->unit()->tests(); + + $this->assertCount(1, $tests); + $this->assertInstanceOf(WorkerFirstTest::class, $tests[0]); + } + + public function testIsNamedAfterItsClass(): void + { + $this->assertSame(WorkerFirstTest::class, $this->unit()->name()); + } + + private function unit(): TestClassWorkUnit + { + return new TestClassWorkUnit( + 3, + WorkerFirstTest::class, + [new WorkerFirstTest('testStartsTheProcessLocalCounter')], + ); + } +} diff --git a/tests/unit/Runner/Parallel/WorkerPoolTest.php b/tests/unit/Runner/Parallel/WorkerPoolTest.php new file mode 100644 index 00000000000..48c012ea6ae --- /dev/null +++ b/tests/unit/Runner/Parallel/WorkerPoolTest.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\Runner\Parallel; + +use function sort; +use PHPUnit\Event\Emitter; +use PHPUnit\Event\Facade; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Large; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\TestRunner\ChildProcessResultProcessor; +use PHPUnit\Runner\CodeCoverage; +use PHPUnit\TestFixture\ParallelWorker\WorkerFirstTest; +use PHPUnit\TestFixture\ParallelWorker\WorkerSecondTest; +use PHPUnit\TestRunner\TestResult\PassedTests; +use PHPUnit\Util\PHP\Job; +use PHPUnit\Util\PHP\JobRunner; + +#[CoversClass(WorkerPool::class)] +#[CoversClass(TestClassWorkUnit::class)] +#[CoversClass(CompletedWorkUnit::class)] +#[CoversClass(PersistentWorker::class)] +#[UsesClass(JobRunner::class)] +#[UsesClass(Job::class)] +#[Large] +final class WorkerPoolTest extends TestCase +{ + public function testRunsUnitsAcrossWorkersAndReportsEachAsCompleted(): void + { + $units = [ + new TestClassWorkUnit(0, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + new TestClassWorkUnit(1, WorkerSecondTest::class, [new WorkerSecondTest('testThatFails')]), + ]; + + $completed = $this->execute($this->pool(2), $units); + + $this->assertCount(2, $completed); + + foreach ($completed as $unit) { + $this->assertFalse($unit->crashed()); + $this->assertNotSame('', $unit->serializedResult()); + $this->assertNotNull($unit->nonce()); + } + + $this->assertSame([0, 1], $this->indexesOf($completed)); + } + + public function testReportsACrashedUnitWhenItsWorkerDies(): void + { + $units = [ + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatKillsTheWorkerProcess')]), + ]; + + $completed = $this->execute($this->pool(1), $units); + + $this->assertCount(1, $completed); + $this->assertTrue($completed[0]->crashed()); + } + + public function testReportsRemainingUnitsAsCrashedWhenEveryWorkerHasDied(): void + { + // The only worker dies on the first unit, so the second unit can no + // longer be dispatched anywhere; it must still be reported as crashed + // rather than silently lost. + $units = [ + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatKillsTheWorkerProcess')]), + new TestClassWorkUnit(1, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + ]; + + $completed = $this->execute($this->pool(1), $units); + + $this->assertCount(2, $completed); + $this->assertSame([0, 1], $this->indexesOf($completed)); + + foreach ($completed as $unit) { + $this->assertTrue($unit->crashed()); + } + } + + public function testReportsAUnitWhoseDataCannotBeSerializedAsCrashedAndKeepsRunning(): void + { + $test = new WorkerFirstTest('testStartsTheProcessLocalCounter'); + $test->setData('with-closure', [static fn (): null => null]); + + $units = [ + new TestClassWorkUnit(0, WorkerFirstTest::class, [$test]), + new TestClassWorkUnit(1, WorkerSecondTest::class, [new WorkerSecondTest('testThatFails')]), + ]; + + $completed = $this->execute($this->pool(1), $units); + + $this->assertCount(2, $completed); + + $byIndex = []; + + foreach ($completed as $unit) { + $byIndex[$unit->unit()->index()] = $unit; + } + + $this->assertTrue($byIndex[0]->crashed()); + $this->assertNotNull($byIndex[0]->message()); + $this->assertStringContainsString('cannot be serialized', (string) $byIndex[0]->message()); + + // The unit that could be serialized still ran on the same worker. + $this->assertFalse($byIndex[1]->crashed()); + } + + public function testRedistributesRemainingUnitsAcrossSurvivingWorkersWhenAWorkerDies(): void + { + $units = [ + new TestClassWorkUnit(0, WorkerSecondTest::class, [new WorkerSecondTest('testThatKillsTheWorkerProcess')]), + new TestClassWorkUnit(1, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + new TestClassWorkUnit(2, WorkerFirstTest::class, [new WorkerFirstTest('testStartsTheProcessLocalCounter')]), + ]; + + $completed = $this->execute($this->pool(2), $units); + + $this->assertCount(3, $completed); + $this->assertSame([0, 1, 2], $this->indexesOf($completed)); + + $crashed = []; + + foreach ($completed as $unit) { + $crashed[$unit->unit()->index()] = $unit->crashed(); + } + + $this->assertTrue($crashed[0]); + $this->assertFalse($crashed[1]); + $this->assertFalse($crashed[2]); + } + + /** + * @param list $units + * + * @return list + */ + private function execute(WorkerPool $pool, array $units): array + { + $completed = []; + + $pool->start(); + + try { + $pool->run( + $units, + static function (CompletedWorkUnit $unit) use (&$completed): void + { + $completed[] = $unit; + }, + ); + } finally { + $pool->stop(); + } + + return $completed; + } + + /** + * @param positive-int $numberOfWorkers + */ + private function pool(int $numberOfWorkers): WorkerPool + { + $processor = new ChildProcessResultProcessor( + new Facade, + $this->createStub(Emitter::class), + new PassedTests, + new CodeCoverage, + ); + + $jobRunner = new JobRunner($processor); + + $workers = []; + + for ($id = 0; $id < $numberOfWorkers; $id++) { + $workers[] = new PersistentWorker($jobRunner, $id); + } + + return new WorkerPool($workers); + } + + /** + * @param list $completed + * + * @return list + */ + private function indexesOf(array $completed): array + { + $indexes = []; + + foreach ($completed as $unit) { + $indexes[] = $unit->unit()->index(); + } + + sort($indexes); + + return $indexes; + } +} diff --git a/tests/unit/Runner/Phpt/ParserTest.php b/tests/unit/Runner/Phpt/ParserTest.php index 4cc1d121d73..cc5d88fe006 100644 --- a/tests/unit/Runner/Phpt/ParserTest.php +++ b/tests/unit/Runner/Phpt/ParserTest.php @@ -149,6 +149,44 @@ public function testParseIniSectionHandlesZendExtensionAsArray(): void $this->assertSame(['opcache.so', 'xdebug.so'], $result['zend_extension']); } + #[TestDox('parseConflictsSection() returns one key per line')] + public function testParseConflictsSectionReturnsOneKeyPerLine(): void + { + $parser = new Parser; + + $this->assertSame( + ['shared-resource', 'another-resource'], + $parser->parseConflictsSection("shared-resource\nanother-resource"), + ); + } + + #[TestDox('parseConflictsSection() recognizes the reserved key all')] + public function testParseConflictsSectionRecognizesAll(): void + { + $parser = new Parser; + + $this->assertSame(['all'], $parser->parseConflictsSection('all')); + } + + #[TestDox('parseConflictsSection() strips comments and ignores blank lines')] + public function testParseConflictsSectionStripsCommentsAndBlankLines(): void + { + $parser = new Parser; + + $this->assertSame( + ['shared-resource', 'another-resource'], + $parser->parseConflictsSection("# a comment\nshared-resource\n\n # indented comment\nanother-resource\n"), + ); + } + + #[TestDox('parseConflictsSection() returns no keys for an empty section')] + public function testParseConflictsSectionReturnsNoKeysForEmptySection(): void + { + $parser = new Parser; + + $this->assertSame([], $parser->parseConflictsSection("\n# only a comment\n")); + } + #[TestDox('FILE_EXTERNAL sets FILE_EXTERNAL_PATH to resolved path of external file')] public function testFileExternalPathIsSet(): void { diff --git a/tests/unit/TextUI/Configuration/Cli/BuilderTest.php b/tests/unit/TextUI/Configuration/Cli/BuilderTest.php index 76ace63f49b..70b4a4f7866 100644 --- a/tests/unit/TextUI/Configuration/Cli/BuilderTest.php +++ b/tests/unit/TextUI/Configuration/Cli/BuilderTest.php @@ -1473,6 +1473,26 @@ public function testProcessIsolationMayNotBeConfigured(): void $configuration->processIsolation(); } + #[TestDox('--parallel')] + public function testParallel(): void + { + $configuration = (new Builder)->fromParameters(['--parallel', '4']); + + $this->assertTrue($configuration->hasNumberOfParallelWorkers()); + $this->assertSame(4, $configuration->numberOfParallelWorkers()); + } + + public function testNumberOfParallelWorkersMayNotBeConfigured(): void + { + $configuration = (new Builder)->fromParameters([]); + + $this->assertFalse($configuration->hasNumberOfParallelWorkers()); + + $this->expectException(Exception::class); + + $configuration->numberOfParallelWorkers(); + } + #[TestDox('--stderr')] public function testStderr(): void { diff --git a/tests/unit/TextUI/PhpHandlerTest.php b/tests/unit/TextUI/PhpHandlerTest.php index 3a543c3aa0a..f51cd24e95d 100644 --- a/tests/unit/TextUI/PhpHandlerTest.php +++ b/tests/unit/TextUI/PhpHandlerTest.php @@ -19,6 +19,7 @@ use function putenv; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DoNotRunInParallel; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Medium; use PHPUnit\Framework\TestCase; @@ -28,6 +29,7 @@ #[Medium] #[Group('textui')] #[Group('textui/configuration')] +#[DoNotRunInParallel] final class PhpHandlerTest extends TestCase { #[BackupGlobals(true)] diff --git a/tests/unit/Util/PHP/RunningJobTest.php b/tests/unit/Util/PHP/RunningJobTest.php index b3d4b334cc2..4c00d68b382 100644 --- a/tests/unit/Util/PHP/RunningJobTest.php +++ b/tests/unit/Util/PHP/RunningJobTest.php @@ -53,6 +53,31 @@ public function testCollectsOutputOfASingleStartedJob(): void $this->assertSame($result, $job->wait()); } + public function testReportsWhetherTheProcessIsStillRunning(): void + { + $job = $this->jobRunner()->start( + new Job( + <<<'EOT' +assertTrue($job->isRunning()); + + $job->write("go\n"); + $job->closeStdin(); + $job->wait(); + + // Once the job has been reaped, its memoized result short-circuits the + // liveness poll. + $this->assertFalse($job->isRunning()); + } + public function testWritesToTheStandardInputOfTheJob(): void { $job = $this->jobRunner()->start(