Test Impact Analysis - #6919
Draft
sebastianbergmann wants to merge 38 commits into
Draft
Conversation
sebastianbergmann
force-pushed
the
feature/test-impact-analysis
branch
from
August 24, 2026 10:31
c866112 to
a703d1f
Compare
API Surface ChangesIf any of the additions below are not intended as public API, mark them with New API SurfaceClasses
Methods
Modified API SurfaceMethods
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6919 +/- ##
============================================
+ Coverage 99.48% 99.49% +0.01%
- Complexity 9608 10028 +420
============================================
Files 922 939 +17
Lines 29171 30191 +1020
============================================
+ Hits 29020 30040 +1020
Misses 151 151 ☔ View full report in Codecov by Harness. |
sebastianbergmann
force-pushed
the
feature/test-impact-analysis
branch
from
August 27, 2026 04:52
ec50b37 to
bdf152b
Compare
…nge affects can be worked out later
…ts tests declare, instead of from what tests execute
…ss in the test index
…out the configuration, the first-party code and the installed packages
…anged, running every test whenever that cannot be determined
… of working that out from what was recorded
…file or from standard input
…amed decide which tests are selected
…n the test run ran every test there is
sebastianbergmann
force-pushed
the
feature/test-impact-analysis
branch
from
September 1, 2026 06:19
bdf152b to
447d287
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, orrecordTestImpactData="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.phpqueries what was recorded, without running a single test: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-impactedruns only the tests a change can affect: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/Servicetells 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, whilegit diff --name-onlyreports what you actually did.Because naming paths one at a time does not compose with the tool that knows what changed,
--impacted-by-filereads a list, one path per line,-being standard input: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-targetsis 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.beStrictAboutCoverageMetadatais 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
requireCoverageMetadataandbeStrictAboutCoverageMetadata.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: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-impactedselected 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:
#[CoversNothing]and a PHPT test#[Depends]And these cause every test to run:
--impacted-byor in a list, is not among the files that were recordedcomposer.lockchangedThe 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-impactedrefuses 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.csvis not executing code, and a coverage target names code units. A new attribute closes that: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
#[CoversNothing]would otherwise get 2.6× slower unasked.#[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.<source>declarations, not the expanded file list. Hashing the list would throw everything away each time a class is added tosrc/, and buys nothing: a new file can only be reached if an existing one changed.--only-impacted,--impacted-byand--impacted-by-fileare command line options only. A switch that makes every run partial does not belong in a file a project commits.