Repository navigation
Test Impact Analysis - #6919
Test Impact Analysis#6919sebastianbergmann wants to merge 79 commits into
Conversation
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❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #6919 +/- ##
=============================================
+ Coverage 82.95% 99.53% +16.58%
- Complexity 4754 10858 +6104
=============================================
Files 279 1000 +721
Lines 13566 32521 +18955
=============================================
+ Hits 11253 32370 +21117
+ Misses 2313 151 -2162 ☔ View full report in Codecov by Harness. |
ec50b37 to
bdf152b
Compare
248a176 to
5aad389
Compare
What is recorded for a test todayThree things:
The gapCode that a test executes, that is not first-party code under public function testAdds(): void
{
$this->assertSame(Helper::expectedSum(), (new Calculator)->add(1, 2));
}What gets recorded for that test is 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
WorkaroundAdding the helper directory to 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)
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. |
40fc113 to
5a7b2ae
Compare
|
When 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 A test is reported under one of these:
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 |
fef6829 to
09b54d8
Compare
|
This should be merged after #6784. It merges cleanly into 1. The test selection must reach the parallel test runnerThis branch passes the tests selected by The parallel branch replaces the body of If the conflict is resolved by simply keeping both sides, 2. The test impact data recorded inside a worker must reach the parentTest impact data is recorded per test in The parallel branch's workers do not do this. To fix this:
|
|
@sebastianbergmann I have been trying it out and I found out a couple of blockers. The main one currently is the re-usability of the execution results across generated configurations. As of now PHPUnit is invalidating the execution data based on a hash containing the following pieces of information:
For the initial test run, Infection applies various transformations to the PHPUnit configuration file. A non-exhaustive list:
However, there is no expected change from the way the dependencies are resolved (except maybe the source scope). Not being able to re-use previously generated execution results would make the feature unusable for infection. Also ideally we should be able to re-use the cache across runs. Indeed, if there is no execution result at first then all the tests are executed, but in the sub-sequent run (even if the first run was done by infection), we should be able to use the results previously generated. I also think it would be worth to notify the user about whether we are doing a cold run (i.e. missing or outdated execution results) vs. a hot run. I still have to play around with how PHPUnit resolves the tests too. A side question, if you consider the following: Intuitively, as a user not familiar with infection internals but aware of the usage of TIA, would it make sense to you that we stick to executing the tests given, i.e. not use TIA here (but we will limit the mutations to |
|
@theofidry Thank you for trying this with Infection, this is exactly the kind of feedback I was hoping for. Re-using the data across generated configurationsYou were right: hashing the configuration file was far too coarse. The data is no longer discarded when the configuration file changes, but only when a setting changes that can change what code a test executes: the Two things worth checking on Infection's side:
Cold vs. hot runWhen there is data but it cannot be used, Your side questionI agree with your intuition: when tests are named explicitly, run those. Mutating only Looking forward to what you find when you look at how the tests are resolved. |
|
Thanks for working on this! I've been trying it against iFixit's PHP application: about 10,800 tests, including WebDriver tests and some HTTP API tests whose code runs in a web server rather than in PHPUnit's process. Dependency edge cases and bugsI found a few bugs/edge cases to consider. Mostly with dependencies not being picked up / tests not running when they should:
require_once 'vendor/autoload.php';
(new \iFixit\Framework())->minimallyInitialize();PHPUnit hashes the bootstrap file, so changing it discards the recording, but it doesn't track the code it calls, like
final class ProvidedTest extends PHPUnit\Framework\TestCase {
public static function cases(): array { return Cases::all(); }
#[PHPUnit\Framework\Attributes\DataProvider('cases')]
public function testIt(string $case): void { $this->assertContains($case, ['a', 'b']); }
}PHPUnit records the test class the provider is in, so changing the provider itself is caught, but it doesn't track the code the provider calls, like
<?php
// file whose only line is Registry::$items[] = 'loaded';
require_once __DIR__ . '/../src/TopLevel.php';
final class RequiresTopLevelTest extends PHPUnit\Framework\TestCase {
public function testIt(): void { $this->assertSame(['loaded'], Registry::$items); }
}PHPUnit records the test file itself, so changing the require_once line is caught, but it doesn't track the file being required, i.e., Helpful ImprovementsWhen looking at adding this to our CI pipeline (GH Actions) I noticed we couldn't reuse the TIA data across checkouts. We currently have multiple action runners on a server we own. If the test impact data is recorded in one checkout and used from another, then PHPUnit can't use it, because the data file names every source file by its absolute path. We'd want to record in a job on the main branch and select tests in pull request jobs, which run on other runners with their own checkouts. For example, a recording made in I'm fairly sure storing paths relative to the project root, like Here is an easy repro script
#!/bin/bash
# Records tiny tests one per process with --record-test-impact-data, then
# asks PHPUnit which tests a change to each file can affect.
#
# PHPUNIT=/path/to/vendor/bin/phpunit bash test-impact-gaps.sh
set -e -o pipefail -o nounset
phpunit=$(realpath "${PHPUNIT:-vendor/bin/phpunit}")
dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT
cd "$dir"
mkdir -p src/iFixit tests
cat >phpunit.xml <<'XML'
<?xml version="1.0"?>
<phpunit bootstrap="tests/bootstrap.php" cacheDirectory="cache" colors="false">
<testsuites><testsuite name="all"><directory>tests</directory></testsuite></testsuites>
<coverage includeUncoveredFiles="false"/>
<source><include><directory suffix=".php">src</directory></include></source>
</phpunit>
XML
cat >tests/bootstrap.php <<'PHP'
<?php
spl_autoload_register(function (string $class): void {
$file = __DIR__ . '/../src/' . str_replace('\\', '/', $class) . '.php';
if (is_file($file)) {
require $file;
}
});
(new \iFixit\Framework())->minimallyInitialize();
PHP
cat >src/Rates.php <<'PHP'
<?php
final class Rates {
public const US = 1;
public static function us(): int { return self::US; }
}
PHP
cat >src/iFixit/Framework.php <<'PHP'
<?php
namespace iFixit;
final class Framework {
public static string $mode = '';
public function minimallyInitialize(): void { self::$mode = 'strict'; }
public static function label(): string { return 'framework'; }
}
PHP
cat >src/Cases.php <<'PHP'
<?php
final class Cases {
public static function all(): array { return [['a'], ['b']]; }
}
PHP
cat >src/Registry.php <<'PHP'
<?php
final class Registry {
public static array $items = [];
}
PHP
cat >src/TopLevel.php <<'PHP'
<?php
Registry::$items[] = 'loaded';
PHP
# "test id|test file|test body"; each test gets a file of its own.
# CallsFrameworkTest, CallsCasesTest and IncludesTopLevelTest aren't checked
# below. They run a line of Framework.php, Cases.php and TopLevel.php inside
# the test, so PHPUnit knows those files: a change to one then shows
# PHPUnit's pick instead of "runs everything".
tests=(
'CallsRatesTest::testIt|tests/CallsRatesTest.php|$this->assertSame(1, Rates::us());'
'ReadsConstantTest::testIt|tests/ReadsConstantTest.php|$this->assertSame(1, Rates::US);'
'CallsFrameworkTest::testIt|tests/CallsFrameworkTest.php|$this->assertSame("framework", \iFixit\Framework::label());'
'ReadsBootStateTest::testIt|tests/ReadsBootStateTest.php|$this->assertSame("strict", \iFixit\Framework::$mode);'
'CallsCasesTest::testIt|tests/CallsCasesTest.php|$this->assertCount(2, Cases::all());'
'IncludesTopLevelTest::testIt|tests/IncludesTopLevelTest.php|require __DIR__ . "/../src/TopLevel.php"; $this->assertSame(["loaded"], Registry::$items);'
)
for test in "${tests[@]}"; do
IFS='|' read -r id file body <<<"$test"
printf '<?php\nfinal class %s extends PHPUnit\\Framework\\TestCase {\n public function testIt(): void { %s }\n}\n' "${id%%::*}" "$body" >"$file"
done
cat >tests/ProvidedTest.php <<'PHP'
<?php
final class ProvidedTest extends PHPUnit\Framework\TestCase {
public static function cases(): array { return Cases::all(); }
#[PHPUnit\Framework\Attributes\DataProvider('cases')]
public function testIt(string $case): void { $this->assertContains($case, ['a', 'b']); }
}
PHP
cat >tests/RequiresTopLevelTest.php <<'PHP'
<?php
// file whose only line is Registry::$items[] = 'loaded';
require_once __DIR__ . '/../src/TopLevel.php';
final class RequiresTopLevelTest extends PHPUnit\Framework\TestCase {
public function testIt(): void { $this->assertSame(['loaded'], Registry::$items); }
}
PHP
record() {
if ! XDEBUG_MODE=coverage php "$phpunit" --record-test-impact-data --record-test-run-history --run-test-id "$1" "$2" >recording.log 2>&1; then
echo "Could not record $1:" >&2
cat recording.log >&2
exit 1
fi
}
for test in "${tests[@]}"; do
IFS='|' read -r id file _ <<<"$test"
record "$id" "$file"
done
record 'ProvidedTest::testIt#0' tests/ProvidedTest.php
record 'RequiresTopLevelTest::testIt' tests/RequiresTopLevelTest.php
# "changed file|a test that depends on it|how"
cases=(
'src/Rates.php|CallsRatesTest::testIt|calls a method in it'
'src/Rates.php|ReadsConstantTest::testIt|only reads a class constant'
'src/iFixit/Framework.php|ReadsBootStateTest::testIt|relies on what bootstrap.php ran'
'src/Cases.php|ProvidedTest::testIt#0|gets its data from a provider'
'src/TopLevel.php|RequiresTopLevelTest::testIt|relies on code a required file ran'
)
for case in "${cases[@]}"; do
IFS='|' read -r changed id how <<<"$case"
echo "$changed" >changed.txt
explanation=$(XDEBUG_MODE=off php "$phpunit" --impacted-by-file changed.txt --explain-impacted 2>&1)
if grep -qF -- " - $id" <<<"$explanation"; then
verdict=picked
elif grep -q '^Every test is run' <<<"$explanation"; then
verdict='picked (runs everything)'
else
verdict=MISSED
fi
printf '%-26s %-30s %-38s %s\n' "$changed" "$id" "$how" "$verdict"
done |
5f4f80f to
52211f0
Compare
|
@jarstelfox Thank you for trying this on a test suite of that size, and for the script: it reproduced every case on the first run, and I kept running it while I worked on them. Three of the four cases you reported are now handled, and sharing a recording between checkouts works. The first case, reading a class constant, remains a limitation, and I explain why below. Every path in the test impact data is now relative to the directory of the configuration file, or to the working directory when there is no configuration file. Changing only the file names would not have been enough: the recording was discarded before any file name was looked at, because the directories configured in For recording on Your cases 2, 3 and 4 have one thing in common: the code runs before the test starts, and PHPUnit only recorded what was executed while a test ran. When it records test impact data, PHPUnit now keeps the code coverage driver running from before the bootstrap script is loaded until the recording is written, and pauses it only while a test runs. What a test executes is recorded for that test, as before. What is executed outside of the tests is recorded for the tests it can affect:
A change to a file in the last group makes PHPUnit run every test, and For your suite, this means that every file in which Recording has become cheaper along the way. PHPUnit used to load its own code before it told the code coverage driver which code is first-party code, and Xdebug decides whether it collects code coverage for a file when that file is compiled. PHPUnit's own code was therefore collected while every test ran, only to be discarded. PHPUnit now tells the driver first. In a synthetic project with 2,000 tests, recording with Xdebug took 8.2 seconds before these changes and takes 6.7 seconds now, although it records more. I have not tried any of this with PCOV yet. While working through your report I found a case you did not list, and that one was a bug. A test that depends on another test with Case 1 stays a limitation. Reading Where a test has a dependency that executing it cannot show, #[UsesFixture('../src/Rates.php')]
final class ReadsConstantTest extends TestCasea change to Because you record on If you can run the branch against your suite again, I would like to know how often a change runs every test because of what the bootstrap script executed, and how long recording takes now. |
|
Thanks for the quick turnaround! I reran the whole suite at 18b2323. Here are the two numbers you asked for, and then three changes that would help with the cases that are left. How often the bootstrap makes a change run every testIn our suite, 101 files execute outside of any test. I replayed the files changed by the last 50 merges to our main branch through That was the common case: 38 of the 50 ran every test for a path the recording has no record of, like a doc, a config file, a SQL migration, a frontend source, or a PHP file no test loads. The other 9 selected a median of 1.5% of the tests, and at most 6.7%. I used How long recording takesThe unit suite, 2,582 tests in one process with Xdebug 3.4.1:
The tests themselves got a lot faster (PHPUnit's The whole suite is 10,839 tests, 552 of them WebDriver tests, recorded one PHPUnit process per test, 8 at a time. That took 73.6 minutes with 1. Count the files a test's process loaded, when the test runs alone in itCase 1 turned out to be bigger than constants. On the real suite, the recording also missed tests that call a method the class inherits, read the class by reflection, or mock it. None of them execute a line of the file they depend on. The one that worried me is a test that checks who may open each of our admin pages. Many of those pages inherit These are the same kind as the cases you listed (enum cases, interfaces, a parent's property defaults), and I agree static analysis isn't the way to go. But loading gives a runtime rule whose limit fits in a sentence: a test depends on every file its process loaded. There's no analysis, and it catches the dynamic cases too, since It only works when a test runs alone in its process, since PHP loads a file once per process. That's already how we record (one For the two concerns you listed for this idea, very large recordings and selections that rarely narrow, here are our numbers, leaving Where the recording does know a file, it picks fewer tests (206 against 294 per file, over 3,612 files), and the difference is exactly the tests that loaded the file without executing it. Across the 50 merges, letting the recording decide instead of loaded files skipped only 1 to 10 more test methods, on 4 of them, and every one of those tests used the changed file in one of the ways above. Because of that, we went back to our own record for every test. One more thing for recordings made one test per process: they never list the After one run of every test instead, the same check says 2. A way to add files to the running test's recordFor the WebDriver and HTTP API tests, you suggested All we'd need from PHPUnit is a way to add files to what's recorded for the test that's running, from the test or from an extension, before PHPUnit stores it. Like // A sketch, not a real API: in tearDown(), or an extension subscribed to Test\Finished
TestImpact::addDependencies(...$filesTheServerLoadedForThisTest);Then the recording would cover these tests too, and we could let PHPUnit decide every test instead of keeping our own record alongside it. A bigger version would be PHPUnit shipping the server side too, the way phpunit-selenium collects code coverage from a web server: a 3. Inherit a class-level
|
18b2323 to
e93d35c
Compare
|
@jarstelfox Thank you for running the branch against your suite again, and for the numbers. All three of your requests are implemented. With your reproducer, PHPUnit now picks the test on every row except the last one, which is the row where it should not. Recording your suite with one What a test's process loadedWhen a test runs in a process of its own, PHPUnit now records every first-party file that process loaded for it, in addition to what the test executed. A test runs in a process of its own when it runs in process isolation, or when it is the only test of the test run. Your This covers calling a method a class inherits, reading a class by reflection, creating a test double for it, reading one of its constants, and PHP loads a file only once per process. In a process that runs many tests, a file is loaded by whichever test needs it first, and that says nothing about the tests that come after it. That is why this only applies to a test that has a process to itself. The rule now reads: a test that runs in a process of its own depends on every first-party file that process loaded, and any other test depends on the first-party files it executed a line of. For such a test, this changes what I wrote last time: a class the bootstrap script merely loads now counts, because it was loaded in the process the test ran in. Adding what a test depends on while it runsA test can now register files and directories it depends on while it runs: abstract class WebDriverTestCase extends TestCase
{
#[After]
protected function recordWhatTheServerLoaded(): void
{
$this->registerFixture(...$this->filesTheServerLoadedFor($this->token));
}
}What is registered is recorded for the test together with what it executed and what it declares with A relative path is resolved against the working directory at the time of the call. A path that does not exist triggers a warning, and so does an empty path. Nothing happens when no test impact data is recorded. For a test that is skipped or marked incomplete, nothing is recorded at all, so what it registered is dropped as well. Registering works for a test that runs in process isolation, too. Paths are recorded as they are named, just like those of There is no way to register paths from an extension.
|
9a1c62d to
9a5f363
Compare
…g can be affected by what changed
…ge driver is available
…erage metadata is required for all tests
… and why each of them can be
…ing that none was recorded
…e a test executes changed, not whenever the configuration file changed
…onfiguration file so that what is recorded in one checkout can be used in another
… as the paths it is asked about are
…est impact data, and run every test when one of them changed
…and the methods called before and after the tests of a test class execute, for the tests it can affect
…r the tests of every class that extends it
…, so that they are recorded as test impact data together with what it executed
…tribute is written in, and quote the path in the warnings about fixtures that do not exist
…cess of its own loaded, whether a line of them was executed or not
… includes what that process loaded
…ing on changed or was added
…ys that an added file can only affect a test through a file that was changed to use it
5c9e134 to
4e4979e
Compare
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.--do-not-record-test-impact-dataturns recording off for a single run, and so does--no-coverage.Recording needs a cache directory, a code coverage driver that supports line coverage, and
<source>. When one of them is missing, PHPUnit warns that test impact data will not be recorded and runs the tests anyway.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. For a file that was executed outside of any test, the answer is that every test depends on it.
Selecting
--only-impactedruns only the tests a change can affect:The first line says how many tests were left out, the second when what the selection rests on was recorded, and the summary reports the tests that actually ran. Nothing pretends that the test that was left out passed. When the tests are filtered as well, with
--filteror--groupfor instance, the line does not say how many tests are not run, because the filter decides that afterwards.When no test can be affected by what changed, no test is run. That is not an empty test suite, and
--fail-on-empty-test-suitedoes not make such a run fail.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. A relative path is resolved against the working directory, as version control names it. A directory stands for everything that was recorded beneath it, and a file in a directory that a test declared as a fixture selects that test.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.
--explain-impactedsays, without running a single test, which tests a test run would select and why:It asks the same question a test run asks, with the same input, so what it reports is what such a test run does. The other reasons it reports are that a test has never been recorded, that it did not pass when it was last run, that it depends on a test that can be affected by what changed, that another test that is run depends on it, and that it is not a test method and can never be recorded.
What is recorded
For each test, PHPUnit records:
<source>that the test executed#[UsesFixture]or registers withregisterFixture()<source>that this process loaded, whether a line of it was executed or notA test run that consists of a single test counts as a process of its own. Loading is what shows that a test uses an interface, an enum case or a class constant, none of which executes a line, but only a process that runs a single test shows which test loaded what.
What is executed outside of any test is recorded as well, for the tests it can affect:
The repetitions of a repeated test and the attempts of a retried test are recorded as the test they are repetitions and attempts of. A PHPT test is never recorded.
Files are named relative to the directory of the configuration file, or relative to the working directory when there is none, so that what is recorded in one checkout can be used in another.
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.Two settings decide whether that is sound.
requireCoverageMetadatamakes it complete: the mode is refused unless code coverage metadata is required for all tests.beStrictAboutCoverageMetadatamakes it correct: 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.What was recorded in one way is of no use to a test run that records in the other. Such a test run discards it instead of adding to it, and a selection based on it runs every test.
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, with 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]when the data is derived from code coverage targets#[Depends], on a test that can be affected by what changed: what it is given is not something it executes#[Depends]And these cause every test to run:
--impacted-byor in a list, is not among the files that were recorded, or no test is recorded as depending on itWhat was recorded is no longer usable when it was recorded with another version of PHPUnit or of PHP, or when one of these changed since: a setting in the configuration that can change what code a test executes, a bootstrap script, what is first-party code, or
composer.lock. A PHP setting given with-dcounts as configuration. A setting that only changes how results are reported does not.That 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.
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. For the same reason, a run that did not run every test does not record a source file that no test refers to as it is now: it did not assess a change to that file, so the next selection still runs every test because of it, and that run records it.
--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.What it cannot see
Observation shows what a test executes, and some dependencies execute nothing:
registerFixture(), or#[UsesFixture]naming its source directory, is how it gets recorded.<source>that a test reads, and that no test declares or registers, is only noticed when it is watched, and then a change to it runs every test.I decided against closing these gaps with static analysis of test code, helper code or production code. It would be a game of cat and mouse, and PHP's dynamic features would keep it from ever being complete. Running every test on the main branch catches what a selection missed.
#[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:The path is relative to the file the attribute is written in. The attribute 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. On a test class, it counts for the tests of every class that extends it. 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.
registerFixture()An attribute needs the path when the test is written. When it is only known while the test runs, which templates were rendered or which tables were queried, for instance, the test can register it:
What is registered is recorded together with what the test executed, also when the test runs in a process of its own. A relative path is resolved against the working directory at the time of the call, and a path that does not exist produces a warning. Nothing is recorded for a test that is skipped or incomplete, registering does nothing when test impact data is not recorded, and deriving the data from code coverage targets ignores it.
Watched files
When PHPUnit worked out what changed by itself, a change to a file outside
<source>that no test was recorded as depending on did not run a single test, while naming the same file with--impacted-byran every test. A test helper or a configuration file was invisible that way. Such files can be watched:A watched file is treated like a file in
<source>that no test executed. Changing it, removing it, or adding a file where files are watched runs every test. If a test is recorded as depending on the file, only that test is run.<directory>takesprefixandsuffixas it does in<source>, and the suffix defaults to.php.The PHP files in the directories of the test suites are watched without any configuration, except for what a test suite excludes and for the test files themselves. A test file that changed is recorded for its tests already. A test file that was added only brings tests that are run because nothing is known about them, and watching it would make every new test file a reason to run everything.
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/. A file that was added runs every test once instead: it may be found by code that scans a directory, without any file that changed leading to it.--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.phpunit.xml, not added by an extension. Which files belong to a project is the project's decision, as it is for<source>.