Summary
PHPUnit should be able to work out which tests can possibly be affected by a change to the code under test, and offer to run only those tests.
The information needed for this already exists inside PHPUnit: when code coverage is collected, PHPUnit knows exactly which source files each individual test executed. Today that knowledge is used only to produce coverage reports and is thrown away at the end of the run. Kept around, it answers a question developers ask constantly: "I changed this file, what do I need to run?"
Motivation
The larger a test suite becomes, the less it is actually used sometimes.
A suite that takes ten minutes is not run after every change; it is run before a commit, or before a push, or not at all until CI complains. The feedback loop that unit testing exists to provide gets stretched until it stops being a loop. Developers work around this by hand: running a single test class, guessing at a --filter pattern, remembering which directory "probably" covers the thing they touched. Those guesses are quick, and they are wrong often enough to matter and miss the test three layers away that also exercised the changed line.
PHPUnit is in a better position to answer this than the developer is, because it has observed what every test actually executed rather than what someone assumes it executed.
Where this idea comes from
The idea is old and well studied, under the name regression test selection. Gregg Rothermel and Mary Jean Harrold formalised it in the 1990s and gave the field the property that still matters most: a selection technique is called safe when it selects every test that could expose a fault introduced by the change. Their techniques achieved that by comparing control flow graphs before and after a change. Everything since has been a trade between how safe a technique is and how cheap it is to run.
The name "test impact analysis" comes from industry tooling rather than from the literature. Microsoft shipped a feature under that name in Visual Studio 2010, and Paul Hammant's 2017 article The Rise of Test Impact Analysis carried it into wider use.
What made it practical was lowering the resolution. Ekstazi (ISSTA 2015) showed that tracking dependencies at file granularity, observed dynamically while tests run, is coarse enough to be cheap to collect and precise enough to be useful, which is the granularity this proposal assumes. A different branch of the field, beginning with Meta's predictive test selection in 2018, drops dependency tracking entirely and predicts from historical run data using machine learning. That is a different feature and is not what is proposed here.
Most ecosystems now have something in this space, whether built into the test runner, the build tool, or the CI vendor.
Proposed Feature
Two capabilities, both opt-in:
Recording
During a run in which code coverage is collected, PHPUnit records which source files each test depends on and stores that alongside the other information it already stores between runs. This costs nothing beyond the coverage collection that is already happening.
Selection
On subsequent runs, PHPUnit can compare the current state of the source files against what was recorded, determine which tests can possibly be affected, and run only those.
From the command line this might look like:
$ phpunit --only-impacted
with the set of changed files determined by PHPUnit itself, or supplied explicitly so that it composes with whatever the developer already uses to answer "what changed":
$ phpunit --impacted-by src/Service/Invoice.php
$ git diff --name-only | phpunit --impacted-by-file -
And a read-only form that answers the question without running anything:
$ phpunit --list-impacted-tests --impacted-by src/Service/Invoice.php
Use Cases
Keeping the feedback loop short while working
A developer editing one class re-runs the suite after every save. Instead of ten minutes, they wait for the handful of tests that touch what they just changed. The suite becomes something used continuously rather than something run at checkpoints.
Pre-commit and pre-push checks
A hook that runs the full suite gets disabled within a week. A hook that runs the tests affected by the staged changes stays enabled, because it finishes in seconds and still catches the mistakes that matter.
Fast signal on pull requests
CI runs the affected tests first and reports within a minute, then runs the full suite. Reviewers get an early answer without giving up the guarantee that the complete suite ran before merge.
Understanding the impact before making a change
Before touching a class in unfamiliar code, a developer asks which tests cover it. Today the answer requires generating a coverage report and reading it backwards. This turns it into one command, and the answer is derived from what the tests really executed rather than from #[CoversClass] attributes that may be incomplete or absent.
Spotting untested changes
A change to a source file that maps to zero tests is worth knowing about. It means either that the file is untested, or that whatever exercises it does so in a way coverage did not observe. Both are useful signals during review.
Working productively in large or inherited suites
In a codebase where the full suite takes long enough that nobody runs it locally, this restores the ability to test at all during development, without requiring the suite to be reorganised or split first.
What users must be able to rely on
This feature trades completeness for speed, and it is only worth having if that trade is made honestly and visibly.
-
It is never enabled by default. Selecting a subset of tests is a decision the user makes deliberately, per run or per configuration.
-
The report never claims a test passed when it was not run. Selecting fewer tests must change the number of tests reported, not fabricate results for the rest. A run of 42 selected tests reports 42 tests and says plainly how many were left out and why. Skipping the rest is not the same as passing them, and the output must not blur the two.
-
When in doubt, the test runs. Anything PHPUnit does not have reliable information about is treated as affected: tests it has never seen before, tests whose own file changed, tests that did not pass on the previous run, and tests that other tests depend on.
-
When the ground shifts, everything runs. A change to the PHPUnit configuration, to the project's dependencies, or to the PHP version invalidates what was recorded, and the full suite runs.
-
The selection is explainable. Users can ask which tests would be selected, and why, without executing anything.
-
CI is not the target. The intended use is shortening the local feedback loop and getting early signal. It does not replace running the full suite before a change is merged or released, and the documentation should say so directly.
Known limitations
Impact analysis is based on observed execution sees PHP code. It does not see everything a test depends on: fixture files, templates, schema definitions, environment configuration, data files, code reached only through reflection, or a test that asserts something is absent. A change to any of those may affect tests that this feature will not select.
This is a real limitation, not a bug to be fixed later, and users need to understand it before they rely on the feature. It also argues for an extension point: PHPUnit itself cannot know that a change to a template or a migration should re-run a particular group of tests, but the frameworks and tools built on top of PHPUnit do, and they should be able to contribute that knowledge.
Non-Goals
-
Replaying previous results. Reporting cached outcomes for tests that were not executed would make the suite faster in exactly the way that matters least and misleading in the way that matters most.
-
Version control integration. PHPUnit should determine what changed by looking at the files it already knows about, and should accept an explicit list of changed files from the user. It should not need to understand branches, rebases, or any particular VCS.
-
Framework-specific knowledge. Rules about templates, routes, migrations or asset pipelines belong in extensions, not in PHPUnit.
Relationship to work already underway
Two features currently in development change what this one would have to build, and one of them changes whether it is worth building at all.
Caching what a test file contains (#6863, feature/test-index)
Selecting fewer tests does not by itself make a run fast. Before PHPUnit can decide that a test has nothing to contribute, it has to load the file that test lives in, and loading test files is most of the cost of a narrow run. That work measured a --group run that selects 98 of a suite's tests at 981 ms without the index and 242 ms with it — the saving is almost entirely in not loading files.
An impact-selected run has exactly that shape: a few dozen tests out of many hundreds. Without the index, the time saved by not running the unaffected tests would be spent discovering them anyway, and the feature would underdeliver on the one thing it promises. These two belong together: "affected by what changed" is a third way of establishing that a file has nothing to contribute, alongside the group and name criteria that work already handles.
It also settles several things this feature would otherwise have to invent, and settles them the same way:
- Deciding whether remembered knowledge is still valid: An index entry is used only while every source file it was derived from still hashes the same, parent classes and traits included. Impact data needs precisely that rule on the test side, and should use the same mechanism rather than a second one.
- Hashing contents rather than trusting timestamps, for reasons that apply here unchanged.
- Failing open: A file that cannot be read or loaded is never remembered, so a later run loads it again rather than skipping past it.
- Being explicit that not loading a file is not the same as skipping a test. That work states the distinction outright: a skipped test is part of the suite and is reported; a test in a file that was not loaded is neither. That is the same honesty requirement stated above, already articulated.
Parallel test execution (#6784, feature/parallel-test-execution)
Workers ship their results home to the parent process, which merges each unit's coverage into its own and remains the single source of truth for output, logging, results and coverage. Per-test attribution survives that merge, so recording the data this feature needs works under --parallel without anything extra.
- Selection size should inform how many workers are started: Starting ten workers to run six selected tests loses more than it saves. The two features are both about making short runs short, and they have to agree on that.
- Work is distributed one test class at a time: When only some tests of a class are affected, the unit sent to a worker has to be the selected subset, not the class.
- Tests that must run in the main process (those marked as not parallelisable, those requiring process isolation, those whose data cannot be serialised, PHPT files declaring a conflict) must still have their dependencies recorded, or they will silently become tests that are never selected again.
- Scheduling: That work notes that recorded durations could seed a longest-first ordering and are not used for it yet. Durations and impact data are both per-test knowledge kept between runs; if this feature lands, they should feed the same scheduler rather than two.
Taken together the three describe one coherent end state: the index avoids loading what cannot matter, impact analysis decides what does matter, and parallel execution runs what is left across all available cores.
Open questions
Is the dependency data PHPUnit can record actually correct enough?
When a test declares #[CoversClass] or #[UsesClass], PHPUnit deliberately narrows the coverage it records to the declared targets. That is the right behaviour for a coverage report and the wrong behaviour for impact analysis: a test that exercises a helper class but does not declare it would have no recorded dependency on that helper, and would therefore not be selected when the helper changes. Silently.
So recording would have to capture what the test executed, independently of what it declares it covers — meaning the impact data and the coverage report would disagree with each other by design. Is that acceptable? If not, this feature cannot be built on observed execution at all, and everything below is moot.
Is requiring a coverage driver a price users will actually pay?
Recording needs pcov or Xdebug, and a recording run is meaningfully slower than a normal run. Users without a driver installed locally get nothing. How many are in that position, and does the feature still pay for itself once the cost of periodically re-recording is counted?
What defines the set of files this feature watches?
<source> is the obvious boundary, but it describes first-party PHP code for the purpose of coverage, not "everything a test might depend on". Changes outside it would be invisible. Is <source> the right boundary, does this need its own configuration, or is the honest answer that anything outside <source> forces a full run?
What is the contract when the selection is empty?
A change that maps to no tests currently has no defined outcome. Is that a successful run, a warning, or a failure? The same question applies when the recorded data is stale or missing: does PHPUnit run everything, or refuse and tell the user to re-record? Both are defensible; picking one late would be a BC problem.
How large is the recorded data, and where does it live?
A per-test-to-per-file map for a suite of any size is substantially larger than what PHPUnit keeps between runs today, and larger than a test index entry. Does it sit alongside that data in the cache directory, or does its size argue for something else? Is it per-machine, or something a team would want to share? If shared, it becomes a format with compatibility obligations.
What happens the first time it selects wrong?
A false negative here does not look like a bug — it looks like a green suite. The failure mode is "PHPUnit told me my tests passed and they did not", which is the single worst bug report this project can receive, and it will arrive regardless of how the feature is documented. Is there a formulation of the output that makes the incompleteness impossible to miss?
Which existing features must work with it on day one?
#[Depends], data providers, process isolation, --order-by, groups and PHPT tests. Some of these are straightforward, some are not.
The harder version of this question is one of sequencing rather than compatibility: this feature needs the test index to deliver what it promises, and should agree with parallel execution about how short runs are made short. Does it wait for both, or is there a first version that stands on its own?
Should recording happen automatically whenever coverage is collected, or only when explicitly requested?
Should the recorded information live with the existing per-test data kept between runs, or separately, given that it is substantially larger?
What is the right output when a change affects no tests — success, or a warning that nothing was verified?
Should a run that selects a subset record dependency data for the tests it did run, or is recording only meaningful for a run of the whole suite?
When only some tests of a class are affected, is the class still the right unit of work to hand to a parallel worker?
Summary
PHPUnit should be able to work out which tests can possibly be affected by a change to the code under test, and offer to run only those tests.
The information needed for this already exists inside PHPUnit: when code coverage is collected, PHPUnit knows exactly which source files each individual test executed. Today that knowledge is used only to produce coverage reports and is thrown away at the end of the run. Kept around, it answers a question developers ask constantly: "I changed this file, what do I need to run?"
Motivation
The larger a test suite becomes, the less it is actually used sometimes.
A suite that takes ten minutes is not run after every change; it is run before a commit, or before a push, or not at all until CI complains. The feedback loop that unit testing exists to provide gets stretched until it stops being a loop. Developers work around this by hand: running a single test class, guessing at a
--filterpattern, remembering which directory "probably" covers the thing they touched. Those guesses are quick, and they are wrong often enough to matter and miss the test three layers away that also exercised the changed line.PHPUnit is in a better position to answer this than the developer is, because it has observed what every test actually executed rather than what someone assumes it executed.
Where this idea comes from
The idea is old and well studied, under the name regression test selection. Gregg Rothermel and Mary Jean Harrold formalised it in the 1990s and gave the field the property that still matters most: a selection technique is called safe when it selects every test that could expose a fault introduced by the change. Their techniques achieved that by comparing control flow graphs before and after a change. Everything since has been a trade between how safe a technique is and how cheap it is to run.
The name "test impact analysis" comes from industry tooling rather than from the literature. Microsoft shipped a feature under that name in Visual Studio 2010, and Paul Hammant's 2017 article The Rise of Test Impact Analysis carried it into wider use.
What made it practical was lowering the resolution. Ekstazi (ISSTA 2015) showed that tracking dependencies at file granularity, observed dynamically while tests run, is coarse enough to be cheap to collect and precise enough to be useful, which is the granularity this proposal assumes. A different branch of the field, beginning with Meta's predictive test selection in 2018, drops dependency tracking entirely and predicts from historical run data using machine learning. That is a different feature and is not what is proposed here.
Most ecosystems now have something in this space, whether built into the test runner, the build tool, or the CI vendor.
Proposed Feature
Two capabilities, both opt-in:
Recording
During a run in which code coverage is collected, PHPUnit records which source files each test depends on and stores that alongside the other information it already stores between runs. This costs nothing beyond the coverage collection that is already happening.
Selection
On subsequent runs, PHPUnit can compare the current state of the source files against what was recorded, determine which tests can possibly be affected, and run only those.
From the command line this might look like:
with the set of changed files determined by PHPUnit itself, or supplied explicitly so that it composes with whatever the developer already uses to answer "what changed":
And a read-only form that answers the question without running anything:
Use Cases
Keeping the feedback loop short while working
A developer editing one class re-runs the suite after every save. Instead of ten minutes, they wait for the handful of tests that touch what they just changed. The suite becomes something used continuously rather than something run at checkpoints.
Pre-commit and pre-push checks
A hook that runs the full suite gets disabled within a week. A hook that runs the tests affected by the staged changes stays enabled, because it finishes in seconds and still catches the mistakes that matter.
Fast signal on pull requests
CI runs the affected tests first and reports within a minute, then runs the full suite. Reviewers get an early answer without giving up the guarantee that the complete suite ran before merge.
Understanding the impact before making a change
Before touching a class in unfamiliar code, a developer asks which tests cover it. Today the answer requires generating a coverage report and reading it backwards. This turns it into one command, and the answer is derived from what the tests really executed rather than from
#[CoversClass]attributes that may be incomplete or absent.Spotting untested changes
A change to a source file that maps to zero tests is worth knowing about. It means either that the file is untested, or that whatever exercises it does so in a way coverage did not observe. Both are useful signals during review.
Working productively in large or inherited suites
In a codebase where the full suite takes long enough that nobody runs it locally, this restores the ability to test at all during development, without requiring the suite to be reorganised or split first.
What users must be able to rely on
This feature trades completeness for speed, and it is only worth having if that trade is made honestly and visibly.
It is never enabled by default. Selecting a subset of tests is a decision the user makes deliberately, per run or per configuration.
The report never claims a test passed when it was not run. Selecting fewer tests must change the number of tests reported, not fabricate results for the rest. A run of 42 selected tests reports 42 tests and says plainly how many were left out and why. Skipping the rest is not the same as passing them, and the output must not blur the two.
When in doubt, the test runs. Anything PHPUnit does not have reliable information about is treated as affected: tests it has never seen before, tests whose own file changed, tests that did not pass on the previous run, and tests that other tests depend on.
When the ground shifts, everything runs. A change to the PHPUnit configuration, to the project's dependencies, or to the PHP version invalidates what was recorded, and the full suite runs.
The selection is explainable. Users can ask which tests would be selected, and why, without executing anything.
CI is not the target. The intended use is shortening the local feedback loop and getting early signal. It does not replace running the full suite before a change is merged or released, and the documentation should say so directly.
Known limitations
Impact analysis is based on observed execution sees PHP code. It does not see everything a test depends on: fixture files, templates, schema definitions, environment configuration, data files, code reached only through reflection, or a test that asserts something is absent. A change to any of those may affect tests that this feature will not select.
This is a real limitation, not a bug to be fixed later, and users need to understand it before they rely on the feature. It also argues for an extension point: PHPUnit itself cannot know that a change to a template or a migration should re-run a particular group of tests, but the frameworks and tools built on top of PHPUnit do, and they should be able to contribute that knowledge.
Non-Goals
Replaying previous results. Reporting cached outcomes for tests that were not executed would make the suite faster in exactly the way that matters least and misleading in the way that matters most.
Version control integration. PHPUnit should determine what changed by looking at the files it already knows about, and should accept an explicit list of changed files from the user. It should not need to understand branches, rebases, or any particular VCS.
Framework-specific knowledge. Rules about templates, routes, migrations or asset pipelines belong in extensions, not in PHPUnit.
Relationship to work already underway
Two features currently in development change what this one would have to build, and one of them changes whether it is worth building at all.
Caching what a test file contains (#6863,
feature/test-index)Selecting fewer tests does not by itself make a run fast. Before PHPUnit can decide that a test has nothing to contribute, it has to load the file that test lives in, and loading test files is most of the cost of a narrow run. That work measured a
--grouprun that selects 98 of a suite's tests at 981 ms without the index and 242 ms with it — the saving is almost entirely in not loading files.An impact-selected run has exactly that shape: a few dozen tests out of many hundreds. Without the index, the time saved by not running the unaffected tests would be spent discovering them anyway, and the feature would underdeliver on the one thing it promises. These two belong together: "affected by what changed" is a third way of establishing that a file has nothing to contribute, alongside the group and name criteria that work already handles.
It also settles several things this feature would otherwise have to invent, and settles them the same way:
Parallel test execution (#6784,
feature/parallel-test-execution)Workers ship their results home to the parent process, which merges each unit's coverage into its own and remains the single source of truth for output, logging, results and coverage. Per-test attribution survives that merge, so recording the data this feature needs works under
--parallelwithout anything extra.Taken together the three describe one coherent end state: the index avoids loading what cannot matter, impact analysis decides what does matter, and parallel execution runs what is left across all available cores.
Open questions
Is the dependency data PHPUnit can record actually correct enough?
When a test declares
#[CoversClass]or#[UsesClass], PHPUnit deliberately narrows the coverage it records to the declared targets. That is the right behaviour for a coverage report and the wrong behaviour for impact analysis: a test that exercises a helper class but does not declare it would have no recorded dependency on that helper, and would therefore not be selected when the helper changes. Silently.So recording would have to capture what the test executed, independently of what it declares it covers — meaning the impact data and the coverage report would disagree with each other by design. Is that acceptable? If not, this feature cannot be built on observed execution at all, and everything below is moot.
Is requiring a coverage driver a price users will actually pay?
Recording needs pcov or Xdebug, and a recording run is meaningfully slower than a normal run. Users without a driver installed locally get nothing. How many are in that position, and does the feature still pay for itself once the cost of periodically re-recording is counted?
What defines the set of files this feature watches?
<source>is the obvious boundary, but it describes first-party PHP code for the purpose of coverage, not "everything a test might depend on". Changes outside it would be invisible. Is<source>the right boundary, does this need its own configuration, or is the honest answer that anything outside<source>forces a full run?What is the contract when the selection is empty?
A change that maps to no tests currently has no defined outcome. Is that a successful run, a warning, or a failure? The same question applies when the recorded data is stale or missing: does PHPUnit run everything, or refuse and tell the user to re-record? Both are defensible; picking one late would be a BC problem.
How large is the recorded data, and where does it live?
A per-test-to-per-file map for a suite of any size is substantially larger than what PHPUnit keeps between runs today, and larger than a test index entry. Does it sit alongside that data in the cache directory, or does its size argue for something else? Is it per-machine, or something a team would want to share? If shared, it becomes a format with compatibility obligations.
What happens the first time it selects wrong?
A false negative here does not look like a bug — it looks like a green suite. The failure mode is "PHPUnit told me my tests passed and they did not", which is the single worst bug report this project can receive, and it will arrive regardless of how the feature is documented. Is there a formulation of the output that makes the incompleteness impossible to miss?
Which existing features must work with it on day one?
#[Depends], data providers, process isolation,--order-by, groups and PHPT tests. Some of these are straightforward, some are not.The harder version of this question is one of sequencing rather than compatibility: this feature needs the test index to deliver what it promises, and should agree with parallel execution about how short runs are made short. Does it wait for both, or is there a first version that stands on its own?
Should recording happen automatically whenever coverage is collected, or only when explicitly requested?
Should the recorded information live with the existing per-test data kept between runs, or separately, given that it is substantially larger?
What is the right output when a change affects no tests — success, or a warning that nothing was verified?
Should a run that selects a subset record dependency data for the tests it did run, or is recording only meaningful for a run of the whole suite?
When only some tests of a class are affected, is the class still the right unit of work to hand to a parallel worker?