Skip to content

Test Impact Analysis - #6919

Draft
sebastianbergmann wants to merge 65 commits into
mainfrom
feature/test-impact-analysis
Draft

sebastianbergmann wants to merge 65 commits into
mainfrom
feature/test-impact-analysis

Conversation

@sebastianbergmann

Copy link
Copy Markdown
Owner

The changes proposed here implement test impact analysis: PHPUnit records which source files each test depends on, and can then run only the tests a change can affect.

Closes #6897.

What it does

Recording

--record-test-impact-data, or recordTestImpactData="true", records while the tests run which source files each test depends on. The data goes into its own file in the cache directory, separate from the test run history.

Querying

--list-tests-that-depend-on src/Money.php queries what was recorded, without running a single test:

Recorded from what the tests executed.

Tests that depend on /…/src/Money.php as it is now:
 - Example\InvoiceTest::testTotalIsTheSumOfItsItems
 - Example\SumTest::testAdds#0

Tests that depend on an earlier version of /…/src/Money.php:
 - Example\FormatterTest::testFormatsAmount

The two lists are kept apart on purpose: the first describes the code as it is now, the second describes tests recorded against a version of that file which no longer exists.

Selecting

--only-impacted runs only the tests a change can affect:

Impact:        26 of 177 tests can be affected by what changed; 151 tests are not run

OK (26 tests, 80 assertions)

The line says how many were left out, and the summary reports the tests that actually ran. Nothing pretends the other 151 passed.

PHPUnit works out what changed by comparing what is there now against what it recorded. --impacted-by src/Money.php --impacted-by src/Service tells it instead and it implies --only-impacted. What you name is the change set: the recorded hashes are not consulted at all. That matters on a fresh checkout, where comparing against the recording reports everything that changed since, while git diff --name-only reports what you actually did.

Because naming paths one at a time does not compose with the tool that knows what changed, --impacted-by-file reads a list, one path per line, - being standard input:

$ git diff --name-only | phpunit --impacted-by-file -

An empty list is an answer rather than a missing one: nothing changed, so nothing that depends on code runs. A list that cannot be read is not an empty list, and PHPUnit stops rather than guessing.

Two ways of maintaining the data

Observation needs code coverage data collection and works no matter what a project declares. --derive-test-impact-data-from-coverage-targets is the alternative for projects that already take coverage targets seriously: it works out what each test depends on from the #[CoversClass] and #[UsesClass] attributes it declares, without code coverage data collection, and without the tests having been run even once. beStrictAboutCoverageMetadata is what makes it sound: under it, a test that executes code it does not declare is already marked risky, so declarations are a superset of execution. PHPUnit warns when the mode is used without the strict check, because then nothing has ever verified the declarations.

Measurements

All from one real project of 177 tests that enables both requireCoverageMetadata and beStrictAboutCoverageMetadata.

wall clock
the suite, no coverage driver 5.44s
recording derived from declarations 5.47s
a coverage run, no recording 33.5s
the same coverage run, recording 87.8s

Recording by observation costs 2.6× on a run that was already collecting coverage, and all of it lands in the tests declaring #[CoversNothing], which have to be collected for and are usually the slow ones. Deriving from declarations is free.

For 165 of 165 tests that declare targets, the declarations covered everything the test was observed to execute — no exceptions beyond #[CoversNothing]. The price is a coarser selection:

changed file from what tests executed from what tests declare
the matrix class 129 of 177 138
the tuple class 123 164
the world class 16 25
an output mapper 6 6

Declaration is never below observation on any file, and costs between nothing and half as many tests again. Five of the 177 tests declare no targets, are never recorded in that mode, and therefore always run: that is the floor.

Selecting after editing two source files gave 26 of 177 whether the change set was worked out from the hashes or piped in from git diff --name-only. With nothing edited, --only-impacted selected 0 of 177 in 0.14 seconds. The data is about 150 to 350 bytes per test — 27 KiB for this project.

What you can rely on

Everything the analysis has no reliable information about causes the test to run:

  • a test that was never recorded, including a test declaring #[CoversNothing] and a PHPT test
  • a test whose own code changed, or whose data provider changed
  • a test that did not pass when it was last run
  • a test another selected test depends on through #[Depends]

And these cause every test to run:

  • nothing has been recorded yet, or what was recorded is no longer usable
  • a source file changed that no test is recorded as depending on
  • a source file is there that was not there, or was not first-party code, when the recording was made
  • a path that was named, with --impacted-by or in a list, is not among the files that were recorded
  • the configuration file changed, the set of first-party code changed, or composer.lock changed

The last one matters more than it sounds: what was recorded describes one state of the world, and when that state is not what it was, the honest answer is not that some entries are stale but that none of them can be trusted.

--only-impacted refuses to run at all without a cache directory, or when the test run history is turned off, rather than quietly being less careful.

Nothing is kept forever, either. A test run forgets every entry it did not record itself, but only when that run ran every test there is. A run that filtered, that selected by impact, or that stopped at the first failure recorded nothing for the tests it did not run, and nothing tells those apart from tests that are gone, so such a run adds to what is known and takes nothing away.

#[UsesFixture]

Neither observation nor declaration can see a data file: reading invoices.csv is not executing code, and a coverage target names code units. A new attribute closes that:

#[UsesFixture('../fixtures/invoices.csv')]
public static function provideInvoices(): array

It goes on a test class, a test method, or a data provider method: the last is the one that earns its keep, because a provider shared by many tests declares the file once and every test using it inherits the dependency. Directories work too, watched as a whole, so adding a file to one counts as a change. A path that does not exist produces a warning and is ignored, the same treatment a coverage target that cannot be used gets. Unlike coverage targets, nothing can ever verify this attribute; it is a promise the project makes to itself.

Decisions

  • Recording is never automatic, not even when coverage is already being collected. A suite whose slow tests declare #[CoversNothing] would otherwise get 2.6× slower unasked.
  • Tests declaring #[CoversNothing] are collected for in observation mode, with the targets bypassed so nothing reaches the coverage report. The alternative — treating them as affected by everything — would put a floor under every selected run.
  • Impact data is never filtered using coverage targets; the report always is. The two disagree by design. Before this, what the coverage report kept was missing 75% and 94% of the dependencies tests really have on the two suites I measured, because the report is narrowed to what a test declares. That is why the data is collected before the narrowing, which needed php-code-coverage 14.4.0.
  • The data is per-machine and must not be committed. It holds absolute paths and is discarded on a PHPUnit or PHP version change.
  • A test's own files are recorded in the impact data rather than checked against the test index at selection time. The index is written at a different moment, so a run that refreshed the index without recording impact data would report a changed test as unchanged.
  • The assumption hash covers the <source> declarations, not the expanded file list. Hashing the list would throw everything away each time a class is added to src/, and buys nothing: a new file can only be reached if an existing one changed.
  • --only-impacted, --impacted-by and --impacted-by-file are command line options only. A switch that makes every run partial does not belong in a file a project commits.

@sebastianbergmann sebastianbergmann self-assigned this Aug 24, 2026
@sebastianbergmann sebastianbergmann added type/enhancement A new idea that should be implemented feature/test-runner CLI test runner labels Aug 24, 2026
@sebastianbergmann
sebastianbergmann force-pushed the feature/test-impact-analysis branch from c866112 to a703d1f Compare August 24, 2026 10:31
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

API Surface Changes

If any of the additions below are not intended as public API, mark them with @internal in the docblock.

New API Surface

Classes

Methods

Modified API Surface

Methods

  • PHPUnit\TextUI\Configuration\Configuration::__construct
    - public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $recordTestRunHistory, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testRunHistoryFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, bool $coverageHtmlClassView, bool $coverageHtmlFileView, 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 $coverageJsonl, ?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 $failOnSelfDeprecation, bool $failOnDirectDeprecation, bool $failOnIndirectDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnSelfDeprecation, bool $doNotFailOnDirectDeprecation, bool $doNotFailOnIndirectDeprecation, 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 $requireCoverageMetadataOnSmallTests, bool $requireCoverageMetadataOnMediumTests, bool $requireCoverageMetadataOnLargeTests, 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, bool $warnWhenPhpIsNotConfiguredForDevelopment, bool $cacheTestIndex)
    + public function __construct(array $cliArguments, ?string $testFilesFile, ?string $configurationFile, ?string $bootstrap, array $bootstrapForTestSuite, bool $recordTestRunHistory, ?string $cacheDirectory, ?string $coverageCacheDirectory, Source $source, string $testRunHistoryFile, ?string $coverageClover, ?string $coverageCobertura, ?string $coverageCrap4j, int $coverageCrap4jThreshold, ?string $coverageHtml, bool $coverageHtmlClassView, bool $coverageHtmlFileView, 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 $coverageJsonl, ?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 $failOnSelfDeprecation, bool $failOnDirectDeprecation, bool $failOnIndirectDeprecation, bool $failOnPhpunitDeprecation, bool $failOnPhpunitNotice, bool $failOnPhpunitWarning, bool $failOnEmptyTestSuite, bool $failOnIncomplete, bool $failOnNotice, bool $failOnRisky, bool $failOnSkipped, bool $failOnWarning, bool $doNotFailOnDeprecation, bool $doNotFailOnSelfDeprecation, bool $doNotFailOnDirectDeprecation, bool $doNotFailOnIndirectDeprecation, 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 $requireCoverageMetadataOnSmallTests, bool $requireCoverageMetadataOnMediumTests, bool $requireCoverageMetadataOnLargeTests, 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, bool $warnWhenPhpIsNotConfiguredForDevelopment, bool $cacheTestIndex, bool $recordTestImpactData, bool $deriveTestImpactDataFromCoverageTargets)

@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.85632% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 99.51%. Comparing base (f569a92) to head (09b54d8).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/TextUI/Application.php 99.23% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main    #6919      +/-   ##
============================================
+ Coverage     99.49%   99.51%   +0.01%     
- Complexity     9785    10341     +556     
============================================
  Files           952      973      +21     
  Lines         29753    31111    +1358     
============================================
+ Hits          29604    30960    +1356     
- Misses          149      151       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@sebastianbergmann

Copy link
Copy Markdown
Owner Author

What is recorded for a test today

Three things:

  1. the source files the test executed, as observed through the code coverage driver and restricted to what <source> says is first-party code (CodeCoverage::recordTestImpactDataFor());
  2. the paths the test declares with #[UsesFixture];
  3. the files the test class itself is made of: its own file, its parent classes, its traits, and the classes its data providers are methods of (TestFiles::of()).

The gap

Code that a test executes, that is not first-party code under <source>, and that is not a parent class, trait or data-provider class of the test, is in none of those three. The obvious example is a test helper:

src/Calculator.php          <- <source>
tests/CalculatorTest.php
tests/Support/Helper.php    <- executed by the test, in neither <source> nor the test class
public function testAdds(): void
{
    $this->assertSame(Helper::expectedSum(), (new Calculator)->add(1, 2));
}

What gets recorded for that test is src/Calculator.php and tests/CalculatorTest.php. tests/Support/Helper.php is not recorded, so changing it changes nothing that was recorded:

$ # edit Helper.php so that the test would now fail
$ phpunit --only-impacted
Impact:        0 of 1 tests can be affected by what changed; 1 test is not run

No tests executed!            # exit code 0

$ phpunit
FAILURES!
Failed asserting that 3 is identical to 4.

That is the bad shape: the suite reports success while a change that breaks it goes unrun. It is not a stale-data problem; the recording is up to date and correct about everything it covers.

What this is not

  • Not the bootstrap gap: That one was the same finding's other half and is fixed: Assumptions now hashes the bootstrap scripts, so changing one discards the whole recording. That mechanism does not generalise to helpers and discarding everything whenever any test file changes would defeat the point.

  • Not a problem for --impacted-by / --impacted-by-file: Naming the helper falls back to running everything, because the path is not among the files that were recorded:

    Impact:        every test is run: .../tests/Support/Helper.php is not among the files that were recorded
    

    Only the automatic mode (--only-impacted, which works out for itself what changed) is silently wrong.

Workaround

Adding the helper directory to <source> closes the gap for that project:

Impact:        1 of 1 tests can be affected by what changed; 0 tests are not run

The cost is that the helpers then count as first-party code for code coverage too, and show up in coverage reports.

Directions considered, none chosen (yet)

  • Record every file the test loaded: Complete and mechanical, but a test would then depend on the framework, vendor code and its whole autoload graph: very large recordings, and selections that rarely narrow.
  • Record from a declared test-code root: A new configuration element, or reusing the test suite directories. Bounded and predictable, but it needs new configuration and a rule for what belongs in it.
  • Extend #[UsesFixture], or add something like it, to cover code: Cheapest to build and the most precise, but opt-in per test, so the default behaviour stays as it is today; which is exactly what this note is about.

The first two change what "what a test depends on" means, so they want a deliberate decision rather than being settled on the way past.

@sebastianbergmann
sebastianbergmann force-pushed the feature/test-impact-analysis branch from 40fc113 to 5a7b2ae Compare September 9, 2026 05:50
@sebastianbergmann

Copy link
Copy Markdown
Owner Author

When --only-impacted runs more tests than you expected, the run tells you how many tests were selected, but not why any particular one is among them:

Impact:  44 of 2849 tests can be affected by what changed; 2805 tests are not run

Being affected by a change is only one of the reasons a test is run. A test is also run when nothing is known about it, and that number can dominate the selection without anything saying so. Read as it stands, the line above claims something about 44 tests that is only true of a few of them.

The new --explain-impacted CLI I just implemented reports which tests can be affected by what changed and why each of them can be, and it does not run anything:

$ phpunit --explain-impacted

Recorded from what the tests executed.

5 of 2849 tests can be affected by what changed.

2 tests depend on something that changed:
 - App\Tests\Api\ThemeControllerTest::testGetTheme
   src/Http/Controllers/ThemeController.php
 - App\Tests\Api\ThemeControllerTest::testGetThemeNotFound
   src/Http/Controllers/ThemeController.php

3 tests have never been recorded:
 - App\Tests\Models\RegionTest::testHasFallback
 - App\Tests\Models\RegionTest::testHasNoFallback
 - App\Tests\Services\ExportServiceTest::testExports

A test is reported under one of these:

  • depends on something that changed: the file it executed, or that it declares it uses, is not what it was. This is the reason you are looking for, and the file that caused it is named.
  • has never been recorded: nothing is known about the test. It is new, or it was skipped or marked incomplete when it last ran, and a test that executes nothing has nothing to record. Anything unknown is run rather than silently left out.
  • did not pass when it was last run: what was recorded describes a run that did not get through the test.
  • is depended upon by another test that is run: it cannot be left out without the test that depends on it being skipped.
  • is not a test method and can never be recorded: PHPT tests, for example.

That second reason is usually the surprising one. Tests that are always skipped never record anything, so they stay in the selection no matter what you change. They cost almost nothing to run, but until now there was no way to see that this is what was happening.

The option reports exactly what a run with --only-impacted would select, so you can use it to understand a selection before spending the time on it. It also takes the paths you already know changed:

$ git diff --name-only | phpunit --explain-impacted --impacted-by-file -

@sebastianbergmann sebastianbergmann added this to the PHPUnit 13.5 milestone Sep 19, 2026
…ts tests declare, instead of from what tests execute
…out the configuration, the first-party code and the installed packages
…anged, running every test whenever that cannot be determined
@sebastianbergmann

Copy link
Copy Markdown
Owner Author

This should be merged after #6784. It merges cleanly into main on its own, but once the parallel test execution has been merged there will be two functional problems that a textual conflict resolution does not solve:

1. The test selection must reach the parallel test runner

This branch passes the tests selected by --only-impacted from Application into TestRunner::run(), which hands them to TestSuiteFilterProcessor::process().

The parallel branch replaces the body of TestRunner::run() with TestRunnerLifecycle, which calls TestSuiteFilterProcessor::process() without a selection, and adds ParallelTestRunner, which uses the same lifecycle and does not accept a selection at all. Application chooses between the two runners based on numberOfParallelWorkers().

If the conflict is resolved by simply keeping both sides, --only-impacted --parallel=N runs every test: the selection is computed and printed, but never applied. The selection has to be threaded through TestRunnerLifecycle::run() into TestSuiteFilterProcessor::process(), and ParallelTestRunner::run() needs to accept and forward it just like TestRunner::run() does. With that in place, selection under --parallel=2 matches the sequential result on the end-to-end fixtures of this branch.

2. The test impact data recorded inside a worker must reach the parent

Test impact data is recorded per test in CodeCoverage::testImpactData() of the process that runs the test. For tests that run in a separate process, this branch ships what was recorded as testImpactData in the child's result envelope (method.tpl) and records it in the parent in ChildProcessResultProcessor.

The parallel branch's workers do not do this. src/Runner/Parallel/templates/worker.tpl ships only codeCoverage, events, and passedTests in the envelope of a unit, and ResultAggregator only calls ChildProcessResultEnvelope::mergeCodeCoverage(). Everything a worker records is lost. On the tests/end-to-end/cli/test-impact-data fixture, a sequential run records three tests, while the same run with --parallel=2 records only IsolatedCalculatorTest::testAddsInAnotherProcess, which survives because it goes through the separate-process path. Since a run that records overwrites the cache, a parallel run also destroys what a previous sequential run recorded, so subsequent --only-impacted runs select more tests than necessary.

To fix this:

  • worker.tpl must include what was recorded (CodeCoverage::instance()->testImpactData()->recorded()) in the unit's result envelope, in the same way method.tpl does.
  • The recording loop that this branch adds to ChildProcessResultProcessor should move into ChildProcessResultEnvelope::mergeCodeCoverage() (or a sibling method), so that both the separate-process path and ResultAggregator record it into the parent's CodeCoverage::testImpactData().
  • TestImpactData needs a way to forget what was recorded (the interface currently only has record() and recorded()). A persistent worker runs many units, and worker.tpl already clears the code coverage and calls PassedTests::reset() after shipping a unit's envelope. Without a reset, every envelope ships the data of all units the worker has run so far.
  • Until this is done, --record-test-impact-data combined with --parallel should be refused, like the other invalid option combinations this branch already rejects, so that a parallel run cannot silently overwrite the cache with incomplete data.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature/test-runner CLI test runner type/enhancement A new idea that should be implemented

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Test Impact Analysis

1 participant