pekral/test-redundancy
Fewer tests, the same coverage
test-redundancy finds the redundant tests in a PHPUnit or Pest suite — and tells you what each of them still
asserts, so you keep 100% coverage with fewer and simpler tests.
Point it at a test folder or file. It runs the tests once with per-test coverage and lists every test case whose
covered lines other test cases already cover. For each one it says what to do — delete it, merge it into a dataset,
review it, or keep it as the only test of an error path — and what it asserts beyond the tests that cover it. The JSON output is built for AI agents; --format=text
is built for people.
- 🔍 Finds candidates — test cases whose covered lines other test cases already cover, every dataset row separately.
- 🧬 Compares contracts, not only lines —
differsBylists the literals, calls and keys a candidate has and its covering test lacks. - 🧭 Says what to do — every candidate has an
actionand numberedsteps; a test of an error path that no covering test asserts is markedkeep. - ⏱️ Starts where it pays — files and candidates are ordered by the time each candidate took, so the slowest redundant tests come first.
- ✅ Proves nothing was lost —
verifyfails when a source line the last analysis covered is no longer covered. - ⚡ Runs in parallel — the test files are split into balanced groups by their measured durations.
Contents
- Requirements · Installation · Quick start
- Usage · Configuration
- Reading the report · Verifying that coverage stayed · Comparing across files
- Reference: when a candidate is
review· Reference: howdiffersBynames a difference - How it works · Development
Requirements
| Version | |
|---|---|
| PHP | ^8.4 |
phpunit/php-code-coverage |
^10.1, ^11, ^12, ^13 or ^14 — the one your PHPUnit or Pest installs |
| Coverage driver | pcov (the tool limits pcov.directory to the source directories) or xdebug |
| Test runner | vendor/bin/pest or vendor/bin/phpunit |
Installation
composer require --dev pekral/test-redundancy
The tool keeps its local state (the coverage map, the baseline, the file durations and the runner cache) in
.test-redundancy/. Add it to .gitignore:
echo ".test-redundancy/" >> .gitignore
Quick start
-
Analyse a test folder and read the report:
vendor/bin/test-redundancy tests/Unit/Billing --format=text -
Act on each candidate, from the top: the report puts the slowest candidates first. Follow its
steps— see How to read a candidate. -
Verify that every source line the analysed tests covered is still covered:
vendor/bin/test-redundancy verifyverifyexits with1and lists the lost lines when a change dropped coverage.
Usage
# A test folder or a single test file
vendor/bin/test-redundancy tests/Unit/Billing
vendor/bin/test-redundancy tests/Unit/Billing/InvoiceTest.php
# No path: the test files the branch changes against origin/HEAD, or against --base
vendor/bin/test-redundancy
vendor/bin/test-redundancy --base=master
# A report for people instead of JSON
vendor/bin/test-redundancy tests/Unit --format=text
# More or fewer parallel processes than the configuration says
vendor/bin/test-redundancy tests/Unit --processes=8
# After changing the tests: fail when a source line the last analyse covered is no longer covered
vendor/bin/test-redundancy verify
| Command | What it does |
|---|---|
analyse (default) |
Lists the tests whose covered lines other tests already cover, as candidates to review. |
verify |
Fails when a source line the last analyse run covered is no longer covered. |
map |
Builds the per-test coverage map that analyse --across compares with. |
| Option | Commands | Meaning |
|---|---|---|
--format=json|text |
analyse |
json (default, for AI agents) or text (for people). |
--pretty |
analyse, verify |
Indents the JSON, which is compact by default. |
--processes=N |
all | Parallel processes; overrides processes in the configuration. |
--base=<ref> |
analyse |
Git ref for the changed test files when no path is given; default origin/HEAD. |
--across |
analyse |
Compares with every test in the coverage map — see Comparing across files. |
vendor/bin/test-redundancy analyse --help lists every option.
[!WARNING] The tests always run in parallel, on the test tokens 1…
processes. Never start the tool next to another run of the same suite.
How the tool runs your tests
A folder is expanded to its test files first. The files of every run, whether you name a folder, one file or
several paths, are split into up to processes groups that run concurrently, each with its own TEST_TOKEN. Each group
is one non-parallel pest or phpunit process with the coverage settings passed through -d options and the project's
phpunit.xml or phpunit.xml.dist passed through --configuration. A folder without test files runs nothing, and a
*Test.php file that only declares an enum, an interface, a trait, a function or a class without a test method or a
trait, and runs no other statement, is left out of the run. A file whose classes have a parent class is left out only
when none of them extends PHPUnit\Framework\TestCase: a child process loads the file through the runner's
vendor/autoload.php to find that out, and a file that cannot be loaded stays in the run.
Every run stores the time of each test file from its JUnit log in
.test-redundancy/durations.json (next to the map). The next run puts the longest file first and every file into the
group with the smallest total, so no group collects the slowest files; a file without a stored time goes first. Without any
stored time the files are split round-robin. The split changes how long the run takes. For tests that share no state
inside one process it never changes the report or the baseline; a test whose coverage depends on what an earlier test
file in the same group already ran, such as a static cache, can cover other lines in another group.
Every run passes --cache-directory=.test-redundancy/cache, so the coverage analysis cache survives
between runs. vendor/bin/test-redundancy analyse --help lists every option.
Configuration
Optional. Without a file the tool reads src/ and app/, runs 2 parallel processes, and picks vendor/bin/pest
when it exists, otherwise vendor/bin/phpunit. To change that, add test-redundancy.json to the project root:
{
"source": ["app"],
"ignore": "#^app/Providers/#",
"env": { "TELESCOPE_ENABLED": "false" },
"processes": 6,
"map": "storage/test-redundancy/coverage-map.json.gz",
"runner": "vendor/bin/pest"
}
| Key | Default | Meaning |
|---|---|---|
source |
["src", "app"] |
Directories whose lines count. |
ignore |
none | A regex of source paths to leave out, e.g. code every test boots. |
env |
{} |
Environment variables for every test run. Concurrent groups also get TEST_TOKEN 1…N, as paratest workers do, and LARAVEL_PARALLEL_TESTING=1, so a Laravel project gives each group its own test database; set it in env to change it. |
processes |
2 |
Parallel processes, at least 2; --processes overrides it. The tool never runs the tests sequentially. |
map |
.test-redundancy/coverage-map.json.gz |
Where the map command stores the coverage map. |
runner |
auto | Path to pest or phpunit, relative to the project root or absolute. |
Reading the report
How to read a candidate
A candidate runs no line the other tests do not run. Coverage cannot see what a test asserts, so a candidate is not
proof that a test is useless. The action of each candidate says how to judge it:
action |
Meaning | What to do |
|---|---|---|
delete |
The candidate adds nothing new. | Delete it when a test under coveredBy asserts the same input and the same result. |
merge-into-dataset |
It differs only in data from one test of the same file. | Add a row with the values in differsBy to the dataset of that test (or turn it into a dataset test), then delete it. |
review-dataset-row |
A row of the same dataset runs the same lines. | Compare it with the sibling row. Delete it when its input is the same class; keep it for an empty, boundary, invalid or other-tenant input. |
review |
It asserts something the covering tests do not, differs in more than data, is a row of a named dataset another test uses, or could not be parsed. | Judge it yourself. When in doubt, keep it. |
keep |
It asserts a rejection — an error status, a validation error or an exception — and no covering test asserts any rejection. | Keep it. It covers the error path, and the covering tests cover another path through the same lines. |
Keep a test that asserts a different result, a different input class (empty, boundary, another tenant, invalid), an ordering, or a rejection.
The candidates of one file can be removed together without losing a covered line. Re-run the tool after each change.
The report starts where a review saves the most time. The files are ordered by candidateSeconds, the time all their
candidates took in the coverage run, and the candidates of a file by their own seconds. A candidate without a measured
time, such as a test the --across analysis took from the coverage map, comes last in its file.
The JSON report
This is the report for the Pest fixture in tests/Fixtures/pest, shortened to one candidate:
{
"files": [
{
"candidates": [
{
"action": "merge-into-dataset",
"coveredBy": ["it adds two numbers › negative numbers"],
"coveringDiffersBy": ["in:call:add#0/value:-1", "in:call:add#1/value:-2", "in:expect:new:Calculator/call:add/call:toBe#0/value:-3"],
"coveringOnly": [],
"differsBy": ["in:call:add#0/value:0", "in:call:add#1/value:5", "in:expect:new:Calculator/call:add/call:toBe#0/value:5"],
"seconds": 0,
"steps": [
"Add a row with the values in differsBy to the dataset of \"it adds two numbers\".",
"Delete this test.",
"Run vendor/bin/test-redundancy verify; it must report \"ok\": true."
],
"test": "it adds zero",
"unionOnlyLines": []
}
],
"candidateSeconds": 0.008,
"coveredLines": 4,
"failedTests": [],
"file": "tests/CalculatorTest.php",
"passedOnRetry": [],
"testCases": 6,
"testsWithoutSourceLines": []
}
],
"summary": {
"byAction": { "delete": 1, "merge-into-dataset": 1, "review-dataset-row": 1, "review": 0, "keep": 0 },
"candidates": 3,
"candidateSeconds": 0.008,
"failedTests": 0,
"passedOnRetry": 0,
"seconds": 0,
"skippedFiles": 1,
"staleMap": null,
"testCases": 7,
"testFiles": 2,
"testsWithoutSourceLines": 0
},
"tool": "pekral/test-redundancy",
"workflow": ["Goal: 100% coverage with the fewest and simplest tests. Coverage cannot see assertions, so judge every candidate.", "…"]
}
| Field | Meaning |
|---|---|
files |
Only the files with a candidate, a test without source lines or a failed test. summary.testFiles counts every analysed file, and summary.testCases every test case, a skipped file included. |
testCases, coveredLines |
Test cases of the file, and the source lines they cover. |
coveredBy |
A small set of kept tests, picked greedily, that covers every line of the candidate. A test from another file carries its path. A single test that runs every line of the candidate and has an empty differsBy is preferred, so a test that passes the values of a dataset row is covered by that row, and the action is delete. That preferred single test can be a kept test, or a candidate that this report removes after this one. The action of that candidate then decides whether the shared input stays. |
differsBy |
The tokens the candidate has, or has more times, and the closest covering test lacks — see How differsBy names a difference. null when the test or its dataset row cannot be parsed; the first step then says so. |
coveringDiffersBy |
The tokens of the covering test that made the action review although differsBy holds only data or is empty — see When a candidate is review, not delete. It also lists the arguments and the calls of a Pest ->throws(), ->throwsIf() or ->throwsUnless() that only the covering test has, when one test covers the candidate and unionOnlyLines is empty — see the same section. The review steps name them. For a merge-into-dataset candidate, the tokens of the covering test in the places where differsBy lists the tokens of the candidate, so the report holds the values of both dataset rows. Empty for every other candidate. |
coveringOnly |
For a candidate with an empty differsBy: every token the covering tests have and the candidate lacks, assertions included, such as in:expect:property:subscriber/property:tags/call:toBe#0/value:'vip'. An assertion there shows that the covering test asserts more than the candidate on the same lines; the candidate is a weaker copy only when it passes the same input. Empty for every other candidate. |
seconds, candidateSeconds |
The time of the candidate in the coverage run, in seconds, and the sum over the candidates of the file and of the report (summary.candidateSeconds). seconds is null when the run did not measure the test, such as a test the --across analysis took from the coverage map. The time includes the coverage overhead, so compare it between candidates, not with a normal test run. |
unionOnlyLines |
The source lines of the candidate that the covering test with differsBy: [] does not run, because only the union of the coveredBy tests reaches them. The action is then review, and the steps say so. Empty for every other candidate. |
steps |
What to do with the candidate, naming every covering test, or the sibling dataset row. The last step always runs verify. A merge-into-dataset candidate whose covering test is a row of a dataset test gets the step Add a row with the values in differsBy to the dataset of "<test>"., because that test already has a dataset; when that dataset is a named dataset (dataset('name', …)) that other tests of the same file also use with ->with('name') at any position of their chain or as any argument of a ->with(), the step names the dataset and those tests instead, because the new row is added to each of them (a name passed through a variable, a constant or a spread argument, a test the analysis does not parse, and a test inside a describe() block bound with describe(…)->with('name'), are not detected, so the step stays the plain one); a plain covering test gets Turn "<test>" into one test with a dataset and add a row with the values in differsBy. |
action |
delete — nothing new; merge-into-dataset — differs only in data from one test of the same file; review-dataset-row — a row of the same dataset runs the same lines; review — asserts something the covering tests do not, differs from one test of the same file in more than data, is a row of a named dataset that another test uses, or could not be parsed; keep — asserts a rejection that no covering test asserts, see Tests of an error path. When a candidate is review, not delete lists the rules for delete and merge-into-dataset. |
testsWithoutSourceLines |
Tests that run no line under source. They are never candidates; check what they test, or the source setting. |
failedTests |
The test cases that failed in the coverage run, each as {"test": …, "message": …} with the failure message. The analysis leaves them out: a failed test case is never a candidate and never a covering test, and it is not counted in testCases. See Failed tests. |
summary.failedTests |
The number of failed test cases in the report. |
passedOnRetry, summary.passedOnRetry |
The test cases that failed in the coverage run and passed in the retry of their files, and their number. They are analysed like every other test case. See Failed tests. |
summary.staleMap |
With --across: null, or the map commit and the source files changed since it was built. |
summary.seconds |
How long the analysis took. |
summary.skippedFiles |
The number of test files analyse did not run, because each declares one test case and no dataset — see Test files with a single test case. They count in summary.testFiles and summary.testCases as one test case each and have no candidate. |
workflow |
The rules an agent follows for the whole report. |
Tests of an error path
A test that asserts a rejection is keep when no covering test asserts any rejection. The covering tests then run the
same lines on another path, such as the success path, and deleting the test would lose the only check of the error
path. A rejection is one of these calls in the test, outside a string or a constant:
- an HTTP status assertion of Laravel:
assertStatus()with a literal from 400 to 599,assertBadRequest(),assertUnauthorized(),assertForbidden(),assertNotFound(),assertUnprocessable(),assertServerError()and the other error status assertions; - a validation error:
assertInvalid(),assertJsonValidationErrors(),assertJsonValidationErrorFor(),assertSessionHasErrors(),assertSessionHasErrorsIn(); - an exception: Pest
toThrow(),->throws(),->throwsIf(),->throwsUnless(), PHPUnitexpectException()and its variants, and LaravelassertThrows(). A negated->not->toThrow()is not a rejection.
A candidate that differs from one test of the same file only in data stays merge-into-dataset, even when the data is
an error status: the new row keeps the case. When a covering test asserts a rejection too, even another one, the
candidate stays review, because the two tests can check two inputs of the same error path. A status passed as a
constant, such as Response::HTTP_NOT_FOUND, is not recognized: the analysis loads no class.
Failed tests
A failing test case does not discard the run. When a test case fails, analyse runs the files of the failed test cases
once more. A test case that passes in this retry is analysed like every other test case and is listed in
passedOnRetry, because its result depends on timing, the order of the tests or shared state. When the retry run itself
aborts, such as on a fatal error, the result of the first run stays. analyse leaves every test case that failed in the
retry too out of the analysis, so
it is never a candidate and never a covering test. Leaving out a covering test can only remove candidates, so the
result stays safe. The JSON report lists each of them with its failure message in failedTests, and the text format
prints a Failed tests list for the file. The coverage of every other test case is complete, and the measured durations
of every worker group that finished are stored for the next run.
analyse exits with 1 while a test case fails, so CI still notices it. Fix the failed tests and run analyse again
to include them. A process that ends without a coverage file or a JUnit log, such as a fatal error or a timeout, still
aborts the run. map and verify still abort when a test case fails.
Test files with a single test case
Without --across, analyse compares a test case only with the other test cases of the same file, so a file with one
test case can never hold a candidate. analyse therefore does not run such a file. It counts the test cases of each
file before the coverage run with the same nikic/php-parser fingerprints as the rest of the report, and skips a file
only when all of these hold:
- The file declares exactly one Pest
it()/test()or one PHPUnit test method. - No dataset reaches it: no
->with(), no->repeat(), no#[DataProvider],#[DataProviderExternal],#[TestWith],@dataProvideror@testWith. A named or shared dataset, a dataset the parser cannot resolve, and adescribe()block with a dataset all keep the file in the run, because the number of runs is known only at run time. - The parser resolves every
it()/test()call. A test that a loop generates, a test with a description that is not a string literal, two tests with the same description, and a file that does not parse keep the file in the run.
A skipped file stays in the report: it counts in summary.testFiles and summary.testCases as one test case, it is
counted in summary.skippedFiles, and it has no candidate, no coveredLines and no testsWithoutSourceLines, because
it did not run. The candidates of every other file are the same as when the file runs. --across and map run every
file, because there a single test case is compared with the test cases of other files.
Verifying that coverage stayed
analyse stores the source lines the analysed tests cover in .test-redundancy/baseline.json (next to the map). Add
.test-redundancy/ to .gitignore; the map, the baseline, the file durations and the runner cache are local state. After
the tests change, verify runs the same test files again and compares:
{"baselineLines":4,"instruction":"Every source line the analysed tests covered before is still covered.","lostLines":[],"missingTestFiles":[],"ok":true,"tool":"pekral/test-redundancy","unstableLines":[]}
A test file that analyse skipped stays in the baseline without lines, so verify runs it and reports its deletion in
missingTestFiles. verify cannot know the lines that only a deleted skipped file covered, because analyse never ran it.
It exits with 1 and lists every line in lostLines when a change dropped coverage. After --across, a line another
mapped test file still covers does not count as lost.
Lost lines vs. unstable lines
analyse also stores a hash of every analysed test file, of every covered source file and of the test support files.
The support files are the other files, not named *Test.php, in the top-level directory of a test file, such as
tests/: Pest datasets, tests/Pest.php, base test cases, traits and fixtures. When a line is no longer covered, none
of the test files that covered it changed, its source file did not change and no support file changed, verify lists it
in unstableLines instead of lostLines, exits with 0 and tells the agent not to restore a test. The coverage of such
a test depends on state outside the test run, such as a cache or a database. A line that a changed or deleted test file
also covered, a line of a source file that changed since analyse, and every lost line after a support file was
changed, added or deleted stay in lostLines, and verify exits with 1. When a changed support file is the reason and no
baseline test file is missing, the instruction tells the agent to check that file or to run analyse again, not to
restore a test. When a baseline test file is missing too, the instruction still tells the agent to restore the test and
adds that a changed support file may also be the reason. A baseline written before source file hashes
or support file hashes existed reports every lost line in lostLines; run analyse again to restore the distinction.
Comparing across files
# Per-test coverage of the given tests (default: tests)
vendor/bin/test-redundancy map tests
# Compare a folder or a file with every mapped test
vendor/bin/test-redundancy tests/Unit/Billing --across
--across reads unchanged test files from the map and re-runs only the changed ones. It warns when source files changed
since the map was built. Of two tests that cover each other, it proposes at most one.
Reference
These two sections define exactly when the tool proposes delete or merge-into-dataset, and how it names a
difference. Read them when a verdict surprises you.
When a candidate is review, not delete
Show the rules for delete and merge-into-dataset
delete needs an empty differsBy and every one of these conditions. The tool checks them in this order: while
unionOnlyLines is not empty, coveringDiffersBy stays empty and the steps name only the lines and the covering tests.
- One test runs every line. One covering test runs every line of the candidate. When only the union of the
coveredBytests reaches some lines, they are listed inunionOnlyLinesand the action isreview. - No missing array key. The covering test sets no array key, or sets no key more times, that the candidate lacks.
A missing key is another input shape, so the
key:tokens are listed incoveringDiffersByand the action isreview. A key passed only to an assertion, such as a key of the arraytoEqualexpects, does not count, whether it is a top-level key of that array or a key nested in it. - No missing argument value. The covering test passes no value, or passes no value more times, to a call that is
not an assertion, such as an argument of the code under test (
in:call:getByListIdAndLastId#4/value:'email'). A missing argument value is another input, so the value is listed incoveringDiffersByand the action isreview. A value passed only to an assertion, such as a Pest expectation (toBe) or a PHPUnitassert*orexpect*call, does not count. A value that is not a literal counts too: a variable, a property or a call result in an argument position that the candidate does not fill carries the tokenin:<call>#<position>/non-literal, such asin:function:logEvent#3/non-literalfor the fourth argument$urloflogEvent($account, $campaign, $contact, $url). - No missing call or object. The covering test has a call, function or
newexpression that the candidate lacks, or has it more times than the candidate does, for examplefunction:createVerifiedDomainfor an extra record it creates,call:andThrowandnew:InvalidDomainfor a mock that throws,function:strtoupperfor a wrapping call, orfunction:ob_startcalled twice. Such a call arranges another state of the code under test, so the tokens are listed incoveringDiffersByand the action isreview. An assertion, and a literal that is not an argument, do not count. A call that the covering test only reads in an assertion, such asactiveState()inexpect(activeState())->toBe(0)or$order->total()inassertSame(5, $order->total()), does not count either. A call inside a call that an assertion receives still counts, asstrtoupper()does inexpect(keepSubscribed(collect([strtoupper($email)]))). - No missing arrangement for a test without an assertion. A candidate without an assertion asserts only that the
code under test throws no exception for its input. When the covering test has a call, function,
newexpression or value that the candidate lacks, such asfunction:createDomainfor a record it creates before the call under test, the candidate can be the only test of an empty or missing input. The tokens are listed incoveringDiffersBy, the action isreview, and the steps ask whether the empty or missing input is a case of its own. This holds for a value that is not an argument too, such as a literal the covering test echoes. An assertion and a value or key passed only to an assertion do not count, so a covering test that only asserts more about the same input still leaves the candidatedelete. A Pest->throws(),->throwsIf()or->throwsUnless()with an argument chained on the test counts as an assertion, so a test whose only expectation is such a call is not a candidate without an assertion. - No other test uses the dataset. A row of a named Pest dataset,
dataset('name', …), that another test of the same file also uses with->with('name')cannot be deleted for one test: deleting the row removes it from every test that uses the dataset. Such a row isreview, notdelete, and the first step names the dataset and the other tests. The same step comes first for every other action of such a row. A named dataset defined in another file is not parsed, so its rows are not checked.
A test whose values equal a row of the dataset that covers it is delete, covered by that row, and the row is never a
candidate covered by that test. The row is kept, so the report never proposes to delete the row and to add it back.
merge-into-dataset needs a candidate that is not a dataset row, whose differsBy holds only data, and that differs
only in data from one test of the same file. When that test has a call, function or new expression that the
candidate lacks, or has it more times than the candidate does, the two tests cannot be rows of one test: the tokens are
listed in coveringDiffersBy and the action is review. When that test sets an array key that the candidate lacks,
such as in:call:post#1/key:value:'default_min_spend', a row with the values in differsBy would still set the key,
so the case without the key would be lost: the key: tokens are listed in coveringDiffersBy, the action is review,
and the steps ask whether the code under test reads one of these keys. A key nested in a value that differsBy
already lists does not count, because a row replaces the whole value. For example, the key 'color' in
store(['custom_fields' => ['color' => 'red']]) is nested in the value that differsBy lists as
in:function:store#0/value:'custom_fields'/value:[] for store(['custom_fields' => []]), so that candidate stays
merge-into-dataset. When that test passes a variable or a property that the candidate does not pass, such as
in:function:dispatchBulkStep#0/variable:account/property:name for dispatchBulkStep($account->name) against
dispatchBulkStep('happypet'), a row with the values in differsBy would still pass that variable or property, so the
case of the candidate would be lost: the name tokens (see Argument names) are listed in coveringDiffersBy and the
action is review. A name token or a non-literal token is never data, also when its path crosses the key of an
array, such as in:function:foo#0/value:'k'/function:bar#0/variable:x for foo(['k' => bar($x)]). When that test passes an argument at a position where differsBy lists no value, such as
in:function:store#1/value:2 for store(1, 2) against store(3), a row with the values in differsBy would still
pass the argument, so the case without it would be lost: the argument tokens are listed in coveringDiffersBy, the
action is review, and the steps ask whether the code under test behaves differently without one of these values.
The reverse holds too. When the candidate passes a named or a positional argument that the covering test omits, such
as in:new:PredictedData#newCustomersForecastCount/value:24 for new PredictedData(date: $date, newCustomersForecastCount: 24)
against new PredictedData(date: $date), the covering test relies on the default of the argument, and a row passes
every argument, so the case "the argument is omitted, the default applies" would be lost: the action is review, and
the steps list the arguments of the candidate that the covering test omits and say to merge only when the row passes them
as a spread array that is empty for the covering row. The same holds for a call of a variable, such as
($action)('john@example.com', 20, $events) against ($action)('john@example.com', 20), which carries
function:$action and the position in:function:$action#2/.
The same holds for the expected value of an assertion. When that test expects more than the candidate does, a row
with the values in differsBy would still expect it, so the case of the candidate would be lost. Such tokens are listed
in coveringDiffersBy, the action is review, and the steps say to merge the candidate only into a row that takes its
whole expected value as a value of its own. Three shapes count:
- an expected list with more elements,
in:expect:function:resolve/call:toEqualCanonicalizing#0/value:list-item. A list whose whole expected valuediffersByalready lists as empty, such asin:expect:function:build/call:toBe#0/value:[]fortoBe([])againsttoBe(['x', 'y']), does not count; - an expected array key that the candidate lacks, such as
in:expect:new:Context/call:toArray/call:toBe#0/key:value:'impersonator_email'fortoBe(['impersonated' => false])againsttoBe(['impersonated' => true, 'impersonator_email' => 'a@b.cz']). A key nested in an expected value thatdiffersByalready lists does not count; - an assertion argument at a position where
differsBylists no value, such as the messagein:call:toThrow#1/value:'vocatives_cz.csv:2'oftoThrow(InvalidArgumentException::class, 'vocatives_cz.csv:2'). The failure message of an assertion, such as the$messageoftoBe()orassertSame(), does not count.
When that test is a dataset row whose parameter reaches the expected value, the row replaces the whole expected value, so
none of these shapes counts.
When that test is a dataset row, a new row can pass only the values that the parameters of its closure or test method
take. Each value in differsBy must sit at a position that a parameter reaches, and the type of that parameter must take
the value. Otherwise the action is review, because a row with the value would land in another parameter, would be
dropped, or would throw a TypeError, and the case of the candidate would be lost. This applies to a value at a position
that the body hard-codes, such as false against the literal true in addVarsToUrlQuery($content, $vars, true), to an
argument that the body does not pass, and to a value that the parameter type does not take, such as null for
int $code. A slot the body hard-codes is never row data, also in a list that the body builds around a parameter: in
f([$n, 5]), a row changes only the slot of $n, so a candidate that lacks the 5 there, such as f([1, 6]), is
review. A constant is taken by every type, because its value is not known. A candidate whose differsBy holds only
data against several covering tests or against a test of another file is review too, and so is a dataset row whose
covering test is not a row of the same dataset.
An exception that a Pest test expects through ->throws(), ->throwsIf() or ->throwsUnless() is never data. Such a
call is chained on the whole it() or test(), so a dataset cannot vary it per row. Each argument of such a call
carries the token call:<method>#<position>/ before its value, without the in: prefix of an argument in the body.
For example, ->throws(EmailNotFound::class, 'The website is empty.') carries call:throws#0/const:EmailNotFound::class
and call:throws#1/value:'The website is empty.'. A named argument carries its name instead of its position, such as
call:throws#exceptionMessage/value:'The website is empty.'. An argument that is not a literal or a constant also
carries its source code, such as call:throwsIf#0/source:$onLinux for ->throwsIf($onLinux, …),
call:throwsIf#0/source:fn() => $this->onLinux for a closure, and
call:throws#1/source:"The website {$website} is empty." for an interpolated string. When an argument of such a call
in the candidate differs from the covering test, the candidate is review, not merge-into-dataset: a row with the
values in differsBy would still expect the exception of the covering test, so the case of the candidate would be
lost. The argument can be the class, the message, the code or the condition, and it can be a variable, a property, a
closure, an operator or an interpolated string. A difference only in whitespace or comments is not a difference.
When one test covers the candidate and unionOnlyLines is empty, the arguments and the calls of this kind that only
the covering test has are listed in coveringDiffersBy, such as call:throws#0/const:InvalidUrlAddress::class and
call:throws. The steps then name them and say to move the expectation of both tests into the body as
expect(fn () => …)->toThrow(), with the class and the message as row values. An exception that a test expects in its
body, such as expect(fn () => …)->toThrow(EmailNotFound::class) or PHPUnit's
$this->expectException(EmailNotFound::class) and $this->expectExceptionMessage('…'), is data, because a row can pass
the class or the message to it, so such a candidate can still be merge-into-dataset. The analysis compares the source
code of an argument, never its value at run time. Two tests whose arguments have the same source code are not told
apart, such as ->throws($exception) in both tests while the variable holds another class in each.
The step for an argument that the covering test omits does not read a call of this kind as a call in the body of the
covering test. A method named throws() that the candidate calls in its body, such as $stub->throws(E::class), and
the ->throws(E::class) that the covering test chains on the whole test therefore do not make that step name the
arguments of ->throws() as omitted.
The arguments of the other Pest modifiers chained on the whole test are never data either, because a dataset cannot
vary them per row. The modifiers covered are ->assignee(), ->covers(), ->coversClass(), ->coversFunction(),
->coversTrait(), ->depends(), ->done(), ->fails(), ->flaky(), ->group(), ->issue(), ->note(), ->pr(),
->references(), ->repeat(), ->see(), ->skip(), ->skipOnPhp(), ->ticket(), ->todo() and ->wip(). Each
argument of such a call carries the token call:<method>#<position>/ before its value, in the same way as an argument
of ->throws(). For example, ->group('slow') carries call:group#0/value:'slow', and ->wip(note: 'Check it.')
carries call:wip#note/value:'Check it.'. When an argument of such a call in the candidate differs from the covering
test, such as ->group('a') against ->group('b'), the candidate is review, not merge-into-dataset: a merged
test would keep one value for every row, so the modifier of the candidate would be lost. A modifier without an argument,
such as ->only() or ->skipOnWindows(), carries only its call, such as call:only, which is never data.
The steps of such a review candidate include a step that names each modifier with an argument in differsBy, such as
group(), and says that a dataset cannot vary it per row. The step says to keep the test when the modifier still has a
purpose of its own, such as a group a CI job selects, and to delete the test only when it does not.
The closure of ->after() is never data either, because a dataset cannot vary it per row. Its tokens are collected in
the same way as the tokens of the test body, and each one carries the token call:after#<position>/ before it. For
example, ->after(fn () => cleanup('mail')) carries call:after#0/in:function:cleanup#0/value:'mail'. A read: token
keeps its prefix in front, such as read:call:after#0/call:Mailer::sent. When the closure of the candidate differs
from the covering test, the candidate is review, not merge-into-dataset. Unlike the arguments of the other
modifiers, the closure carries no source: token, because its tokens already describe it. An expect() or an
assert*() call inside the closure counts as an assertion of the test, because Pest runs the closure after the test
and a failure in it fails the test. A test whose only expectation is in its ->after() closure therefore does not get
the step that says the test asserts nothing.
How differsBy names a difference
differsBy lists the literals, constants, functions, calls and new expressions the candidate has and the covering
test lacks.
Show every token rule
-
One covering test, not a union. With several covering tests,
differsByis the smallest difference against one of them. -
Counts. The fingerprint counts each token, so a token the candidate uses more times than the covering test is listed too. The count is the number of places the token appears in the test source, not the number of runs. A call of a callable that a variable, a property, a class constant or an array element holds counts too:
($action)($account)carriesfunction:$action,($this->action)($account)carriesfunction:$this->action,(self::$action)($account)carriesfunction:self::$action,(self::HANDLER)($account)carriesfunction:self%3A%3AHANDLER, and($this->actions['cleanUp'])($account)carriesfunction:$this->actions['cleanUp']. A class constant and the key of an array element are written like aconst:orvalue:token without its prefix, e.g.$actions[0],$actions[-1],$actions[self%3A%3AK]orActions%3A%3AALL['cleanUp']. In them,%,/,#and:are written as%25,%2F,%23and%3A, because/,#and:separate the parts of a token, so($this->commands['import:products'])($account)carriesfunction:$this->commands['import%3Aproducts'], and a constant named like an assertion, such asself::assertReady, is not read as an assertion. A nullsafe method call$action?->handle($account)carriescall:handle, like the method call$action->handle($account). A test that makes such a call once is thereforereviewagainst a covering test that makes it twice, because the covering test makes the call more times (see No missing call or object). A callable that the test names only at run time, such as($this->{$name})(),($this->actions[$name])()or($class::HANDLER)(), carries no token. -
Casts. A cast carries a token for its type at its place, e.g.
in:function:store#0/cast:objectforstore((object) ['code' => null])orin:function:connect#0/cast:intforconnect((int) $id). Each token inside the cast carries the cast in its path, and in an argument also the place of the argument, e.g.in:function:store#0/cast:object/key:value:'code', so two tests with swapped literals in cast arguments, such asstore((int) '5', (int) '6')andstore((int) '6', (int) '5'), aremerge-into-dataset. A token inside a cast in a list carries no place, e.g.in:cast:object/key:value:'code'. An alias carries the token of its type, so(integer)carriescast:intand(double)carriescast:float. A test that has a cast its covering test lacks, or a cast in another place, is thereforereview, notdeleteormerge-into-dataset, because a row built fromdiffersBywould drop the cast and pass another type. A cast that only the covering test has is not indiffersBy. Around a variable or a property that an assertion expects, it carries no other token, soexpect(render())->toBe($x)covered byexpect(render())->toBe((string) $x)is stilldelete. In an argument of a call that is not an assertion, it is listed incoveringDiffersBy, sostore($x)covered bystore((string) $x)isreview. A cast that is the subject of an assertion or an array key carries what it casts, e.g.expect:cast:string/variable:response/call:body/call:toBeorin:function:store#0/cast:string/const:Foo::A/value:1. A cast in a list is ordered by its source, like an array or a call with arguments. -
Loops. A call, function call or
newexpression in the body of aforeach,for,whileordoloop carries aloop:prefix, e.g.loop:call:Batch::import. A call made in a loop and the same call made outside a loop are different tokens, so the action isreview. Two loops differ only in the tokens they carry, never in the number of runs, and a callback passed to a function such asarray_map()is not a loop body. -
Negation. A call made through Pest's
->notor->not()carries anot:prefix, e.g.not:call:toBeEmpty. -
Property-form assertions. A Pest assertion written as a property, e.g.
->toBeEmptyor->not->toBeEmpty, carries the token of its call form. -
Assertion subjects. An assertion carries the value it checks, its subject, as an
expect:prefix. The same assertion on two different subjects therefore gives two different tokens, and the candidate is notdelete.expect($templates->create())->toBeGreaterThan(0)has the tokensexpect:variable:templates/call:create/call:toBeGreaterThanandin:expect:variable:templates/call:create/call:toBeGreaterThan#0/value:0, andexpect($template->order)->toBeGreaterThan(0)hasexpect:variable:template/property:order/call:toBeGreaterThanandin:expect:variable:template/property:order/call:toBeGreaterThan#0/value:0.- The subject of a Pest expectation is the value passed to the nearest
->and()before the call, or else toexpect(). Every call of the expectation chain carries it,->and()and->eachincluded. - The subject of a PHPUnit
assert*call is its last argument that is not a literal or a constant:self::assertSame(3, add(1, 2))has the tokenexpect:function:add/call:self::assertSame. The failure message is never the subject. When every other argument is a literal or a constant, the last of them is the subject, such as'5'in$this->assertSame(5, '5'). - A property carries its name token (see Argument names), e.g.
variable:template/property:order. A call, a function call and anewexpression carry their own token. - A method call also carries its receiver before its own token: a variable by its name as
variable:<name>, also when the test assigns it once, a property by its name token, such asvariable:this/property:repositoryfor$this->repositoryandproperty:self::repositoryforself::$repository, and a call, a function call or anewexpression as its own token.expect($moment->format('Y-m-d'))has the subjectvariable:moment/call:format, andexpect($interval->applyTo($moment)->format('Y-m-d'))hascall:applyTo/call:format. Only the nearest receiver is kept. A literal or a constant as the receiver adds nothing, soexpect(Status::Active->label())has the subjectcall:label. - A variable that the test assigns once in its own statements stands for the value assigned to it:
$sum = add(1, 2); expect($sum)->toBe(3);has the same assertion tokens asexpect(add(1, 2))->toBe(3). - Any other variable carries its name, so
expect($a)->toBe(1)has the tokensexpect:variable:a/call:toBeandin:expect:variable:a/call:toBe#0/value:1. - A closure or an arrow function stands for the value it returns, so
expect(fn () => import(1))->toThrow(A::class)has the subjectfunction:import. The value a closure returns is the expression of each of its ownreturnstatements, and not of areturnstatement in a closure or a function nested in it. - A literal, a constant and any other expression that carries no token, such as the element read
$r['a'], a list, a map or the enum case readStatus::A->value, give no prefix to the token of the assertion, which keeps e.g.call:toBe. When a test has two or more assertions with different such subjects, each argument of these assertions carries its subject in its path: a literal, a constant or an empty array by its token, any other expression by its source code.expect('5')->toBe(5)next toexpect('6')->toBe(6)hasin:expect:value:'5'/call:toBe#0/value:5,expect($r['a'])->toBe(1)next toexpect($r['b'])->toBe(2)hasin:expect:$r['a']/call:toBe#0/value:1, andexpect([1])->toBe(3)next toexpect([2])->toBe(4)hasin:expect:[1]/call:toBe#0/value:3. So a test that pairs the same subjects with swapped values ismerge-into-dataset, notdelete. A test with one such subject keepsin:call:toBe#0/value:5. A test with two or more such subjects isreview, notmerge-into-dataset, against a covering test that asserts other such subjects, because each subject is a place of its own. - A test can assert one subject with two or more different argument lists, e.g.
$mapping->get(1)and$mapping->get(2). Then each of these assertions also has abound:token. The token keeps the arguments of the subject and of the assertion as#<position or name>:<value>, so each expected value stays bound to its argument. The value is the literal. An argument that holds no literal has the names it holds (see Argument names), such asvariable:aorvalue:list-item/element:variable:a, and an argument without names has its source code, such asbar($a). An argument that holds a literal, such as$r['a']or[$a, 1], has its source code as well, so the literal stays in the token.expect($mapping->get(1))->toBe(Column::VALUE)has the tokenbound:expect:variable:mapping/call:get#0:1/call:toBe#0:Column::VALUE, andexpect($manager->consumePipelineSkip($event, pipelineId: 99))->toBeFalse()hasbound:expect:variable:manager/call:consumePipelineSkip#0:variable:event#pipelineId:99/call:toBeFalse. A literal or a constant as the receiver of the subject is kept as well, e.g.bound:expect:const:Status::A/call:label/call:toBe#0:'a'. The value that a cast subject casts is kept by its token when it is a literal or a constant, and by its source code when it carries no token of its own, e.g.bound:expect:cast:int/value:'5'/call:toBe#0:5,bound:expect:cast:int/$r['a']/call:toBe#0:1andbound:expect:cast:object/[1]/call:toEqual#0:1. Soexpect(json_encode($a))->toBe('x'); expect(json_encode($b))->toBe('y');against the same assertions with the values swapped isreview, notdelete. The failure message of the assertion is left out, such as'first'intoBe(Column::VALUE, 'first')or inassertSame(Column::VALUE, $mapping->get(1), 'first'), because it does not change the pairing. Abound:token is not data, so a candidate that expects the same values for swapped arguments, or another value for the same argument, isreview. A test that asserts each subject with one argument list has nobound:token, because its arguments and its expected values already pair up.
- The subject of a Pest expectation is the value passed to the nearest
-
Array keys and values. An array key carries a
key:prefix and the path of its array, the same path a literal in the array has:key:value:'x'for$options = ['x' => 1]that the test never passes on,in:function:store#0/key:value:'x'forstore(['x' => 1]), andin:function:store#0/value:'user'/key:value:'x'forstore(['user' => ['x' => 1]]). An enum case read through->valueor->nameas a key names the property it reads, like an argument value:in:function:store#0/key:const:CouponType::FIXED->valueforstore([CouponType::FIXED->value => true]). A literal under an array key carries the key as a path prefix, e.g.in:value:'enabled'/value:falsefor$options = ['enabled' => false]that the test never passes on, so the same literal under another key, or in a list without a key, is a different token. A map passed to a call also carries the call and the argument position (see Call arguments). -
Order of array keys. An array with two or more keys also carries one token for each pair of its keys, with the earlier key first:
keys:value:'a'>value:'b'for$options = ['a' => 1, 'b' => 2], andin:function:store#0/keys:value:'a'>value:'b'forstore(['a' => 1, 'b' => 2]). An array with three keys has three such tokens. The same keys in another order give other tokens, so two dataset rows whose arrays differ only in the order of their keys differ indiffersBy, and the candidate isreview, notdelete. The order is input when the test reads the array in order, for example in aforeachthat inserts records. A property as a key takes part by its name (see Argument names), e.g.in:function:transfer#0/keys:variable:a/property:id>variable:b/property:idfortransfer([$a->id => 1, $b->id => 1]), and the name is also the path of the value under it, e.g.in:function:transfer#0/variable:a/property:id/value:1. A variable as a key takes part by its name the same way, e.g.in:function:store#0/keys:variable:a>variable:bandin:function:store#0/variable:a/value:1forstore([$a => 1, $b => 2]). A key that no token or name names, such as an element read$keys[0], and an item without a key add nothing. A test whose keys are a part of the keys of its covering test, in the same order, has no order token the covering test lacks. An order token of a key that the covering test lacks is not listed either, because that key is listed already. The order tokens of the covering test are never listed incoveringDiffersBy, because the keys that cause them are listed already. -
Empty arrays. An empty array literal
[]carries the tokenvalue:[]. Under an array key it carries the key path too, e.g.in:value:'tags'/value:[]for['tags' => []]. -
Lists. A non-empty array literal whose elements have no key, and none of which unpacks an array with
..., carries the tokenvalue:list-itemonce per element. The token has the path an element in the place of the list would have, e.g.in:function:notify#0/value:list-itemcounted three times fornotify([$click, $click, $click]). The length of a list therefore counts even when its elements carry no token, such as a variable whose later reads add no token (see Local variables): a test that passes the same item three times in one list lists the token indiffersByagainst a test that passes it once, and is notdelete. A shorter list adds nothing todiffersBy, because a token the candidate has fewer times is never listed. -
Order of list elements. A list with two or more elements also carries one token for each pair of its elements, with the earlier element first, like the order of array keys:
in:function:store#0/items:value:3>value:5forstore([3, 5]), anditems:value:1>value:2for$ids = [1, 2]that the test never passes on. A list with three elements has three such tokens. The same elements in another order give other tokens, so two dataset rows or tests whose lists differ only in the order of their elements, such as[3, 5]and[5, 3], differ indiffersBy, and the candidate isreview, notdelete. An element takes part when it carries a token of its own: a literal, a constant, an enum case, a call or anewexpression without arguments, or an empty array. A variable or a property takes part by its name (see Argument names), e.g.in:function:transfer#0/items:variable:a>variable:bfortransfer([$a, $b]), so[$a, $b]and[$b, $a], or[$a, 1]and[1, $a], differ and neither isdeleteagainst the other. A property takes part by its name, which names its object too, so[$a->id, $b->id]hasin:function:transfer#0/items:variable:a/property:id>variable:b/property:idand isreview, notdelete, against[$b->id, $a->id]. A non-empty array, an operator (see Argument names), and a call or anewexpression with arguments, take part by their source code, e.g.in:function:total#0/items:new Money(1)>new Money(2)fortotal([new Money(1), new Money(2)]),in:function:fullName#0/items:$first . $last>variable:titleforfullName([$first . $last, $title]), anditems:['id' => 1]>['id' => 2]for two rows of a dataset. That source code is normalized: the quotes of a string, the base of an integer, the case of a constant and a comment do not change it, so["id" => 0x10]and['id' => 16]are the same element. A closure adds nothing.differsBylists an order token only when the covering test has the same two elements in the other order. The order token of an element that the covering test lacks is not listed, because that element is listed already. So a test whose list has another or an extra element of any kind staysmerge-into-dataset, and a test that swaps elements the covering test also has isreview, also when another value differs. The order tokens of the covering test are never listed incoveringDiffersBy. -
Order of arranging calls. A test whose body makes two or more calls with literal input also carries one token for each pair of these calls, with the earlier call first, like the order of list elements:
calls:store(1)>store(2)forstore(1); store(2);. A call takes part when it is a statement of the test body, or the value assigned in one, when it is not an assertion, and when its arguments hold a literal or a constant. A call is named by its normalized source code, like a composite list element, without the variable it is assigned to:$matching = Model::factory()->create(['c_fields' => null])takes part asModel::factory()->create(['c_fields' => null]). A test with three such calls has three such tokens. Two tests or dataset rows that make the same calls in another order, such as two tests that create the same records in another order, differ indiffersBy, and the candidate isreview, notdelete. Two tests that make the same calls in the same order under other variable names have the same tokens. A call without literal input, an assertion, and a statement that is not a call add nothing.differsBylists an order token only when the covering test makes the same two calls in the other order, so a test that makes another or an extra call keeps its action. The order tokens of the covering test are never listed incoveringDiffersBy. -
Call arguments. A literal, constant or empty array passed straight to a call or a
newexpression carries the call and the argument position, e.g.in:function:store#0/value:trueandin:function:store#1/value:falseforstore(true, false), so the same literals at swapped positions are different tokens. A named argument carries its name instead of the position, e.g.in:function:store#enabled/value:true. An enum case read through->valueor->namenames the property it reads, e.g.in:function:store#0/const:CouponType::FIXED->valueforstore(CouponType::FIXED->value), so the value, the name and the case itself are different tokens. An element of a list passed straight to a call carries the same path, e.g.in:function:store#0/value:'a'forstore(['a'])andin:function:store#0/const:CouponType::FIXED->valueforstore([CouponType::FIXED->value]). A value under a key of an array passed straight to a call carries the call and the position before the key, e.g.in:function:store#0/value:'enabled'/value:falseforstore(['enabled' => false]), so the same array at swapped positions is a different token. A closure or an arrow function passed to a call passes on the value it returns: that value carries the call and the position of the closure, e.g.in:function:store#0/value:1forstore(fn () => 1). Sostore(fn () => 1, fn () => 2)againststore(fn () => 2, fn () => 1)ismerge-into-dataset. The value a closure returns is the expression of each of its ownreturnstatements (see Assertion subjects). A closure that the test assigns to a variable and then passes on carries no such place, so$f = fn () => 1; $g = fn () => 2; store($f, $g);against the same test with the values swapped isdelete. Any other literal or constant in the body of such a closure carries the place of the closure, thenclosure, then its path inside the closure: each step is the operand of an operator, writtenoperator:<kind>#<place>as in Argument names, or else the part of the PHP syntax tree it sits in, such asstmts#0,condorexpr.$mock->withArgs(fn ($a, $b) => $a === true && $b === true)hasin:call:withArgs#0/closure/expr/operator:booleanand#0/operator:identical#1/value:trueandin:call:withArgs#0/closure/expr/operator:booleanand#1/operator:identical#1/value:true, so each place of a literal that differs inside a closure is its own entry indiffersBy. A key or a cast inside the closure adds no place of its own:$mock->withArgs(fn ($a) => $a === ['approval' => true])hasin:call:withArgs#0/closure/expr/operator:identical#1/items#0/value/value:true. A literal in an argument of a call inside the closure keeps the place of that call. -
Non-literal arguments. An argument of a call that is not an assertion, whose value is not a literal, a constant, an enum case, a list or a map, carries the call and the position followed by
non-literal, such asin:function:recalculate#2/non-literalforrecalculate($job, 5, $campaign->id). It names the position only, so a test that lacks the position differs from the one that fills it. The variable or the property at the position has a token of its own (see Argument names). Both tokens are left out of a difference that names the call itself already, asfunction:recalculatedoes for a call the other test does not make. A variable that the test assigns once and passes on is read as the value it holds, so$url = fake()->url(); store($url)has the call tokens offake()->url()and the token of the position. -
Argument names. A variable or a property passed straight to a call, a function call, a
newexpression or an assertion carries the call and the position followed by its name:variable:<name>for a variable andproperty:<name>for a property, after the name of its object.connect($this->enShop)has the tokenin:function:connect#0/variable:this/property:enShop,recalculate($job, 5, $campaign->id)hasin:function:recalculate#0/variable:jobandin:function:recalculate#2/variable:campaign/property:id, andexpect(linksOf($account))->toContain($canvaUserId)hasin:expect:function:linksOf/call:toContain#0/variable:canvaUserId. A nullsafe property is a property too:connect($shop?->url)hasin:function:connect#0/variable:shop/property:url. A static property is named with its class as written, soconnect(self::$enShop)hasin:function:connect#0/property:self::enShop. A variable or property inside a cast that is the argument carries the position of that argument, sotoBe((string) $campaign->title)hasin:expect:function:resolveUtm/call:toBe#0/variable:campaign/property:titleforexpect(resolveUtm($campaign)). The cast has a token of its own (see Casts). Each variable or property inside an operator, a ternary or an interpolated string that is the argument carries the position of that argument, the place of its operand and the prefixoperand:, because the argument holds the name and is not the name. The place of an operand isoperator:<kind>#<number>: the kind of the operator and the number of the operand, counted from 0 from the left. The kind is the name of the operator in lower case, e.g.plus,minus,concat,coalesce,unaryminus,booleannot,ternaryorinterpolatedstring. A ternary numbers its condition 0, the value if true 1 and the value if false 2. An interpolated string numbers each of its parts, text and names alike. The operator itself carriesoperator:<kind>at its place. An operator inside an operator adds its own place, outermost first. A literal or a constant operand carries its place too, before its own token and with no prefix. SotoBe('no_segment_tag_' . $campaign->title)hasin:expect:function:resolveUtm/call:toBe#0/operator:concat,in:expect:function:resolveUtm/call:toBe#0/operator:concat#0/value:'no_segment_tag_'andin:expect:function:resolveUtm/call:toBe#0/operator:concat#1/operand:variable:campaign/property:title,toBe("no_segment_tag_{$campaign->title}")hasin:expect:function:resolveUtm/call:toBe#0/operator:interpolatedstring#1/operand:variable:campaign/property:title, andconnect($primary ? $enShop : -$debit)hasin:function:connect#0/operator:ternary#0/operand:variable:primary,in:function:connect#0/operator:ternary#1/operand:variable:enShopandin:function:connect#0/operator:ternary#2/operator:unaryminus#0/operand:variable:debit. A test that passes$xand a test that passes$x . $y,$c ? $x : 1,-$xor"{$x}"differ, and neither isdeleteagainst the other. Two tests whose arguments hold another operator, such asfoo($x + 1)andfoo($x - 1),foo(!$x)andfoo(-$x), orfoo(1 + 2)andfoo(1 - 2), differ too. So do two tests whose arguments hold the same names at other places, such asfoo($x . $y)andfoo($y . $x), ortoBe($c ? $x : $y)andtoBe($c ? $y : $x), and a test whose operator has fewer operands than the one of its covering test, such asfoo($x . $y)covered byfoo($x . $y . $z),foo(1 + 2)covered byfoo(1 + 2 + 3), orfoo(f() . 'a')covered byfoo(f() . 'a' . 'b'). The candidate isreviewin each of these cases. Two tests whose literal operands swap places, such asfoo(1 - 2)andfoo(2 - 1), differ by the place of each literal, and the candidate ismerge-into-dataset, as for literal arguments that swap places. These operators are the binary operators, the ternary, the interpolated string, the unary-,+,!and~, and the spread...of an argument. A negative number, such as-5, is a literal and not an operator. A spread argument has the kindspreadand its value is operand 0, sofoo(...$x)hasin:function:foo#0/operator:spreadandin:function:foo#0/operator:spread#0/operand:variable:x, whilefoo($x)hasin:function:foo#0/variable:x. A test that spreads a variable or a property and a test that passes it plain, such asfoo(...$x)andfoo($x), differ, and neither isdeleteagainst the other. The same holds for an argument that carries no name, such as a call, a list or a constant, of a call that is not an assertion:foo(f())andfoo(...f()),foo([1, 2])andfoo(...[1, 2]), orfoo(Foo::ITEMS)andfoo(...Foo::ITEMS)differ, and neither isdeleteagainst the other. When only the covering test spreads such an argument, the candidate isreviewand itscoveringDiffersBynames the spread, such asin:function:foo#0/operator:spread. The following shapes are stilldelete:- An operator in no argument of a call or an assertion, such as one that a closure returns or one assigned to a
variable that the test passes on. It carries no token, so
foo(fn () => $x + 1)againstfoo(fn () => $x - 1)and$y = $x + 1; foo($y)against$y = $x - 1; foo($y)aredelete. An operator in an argument of a call inside a closure carries its token, sofoo(fn () => bar($x + 1))againstfoo(fn () => bar($x - 1))isreview. - An operator other than a spread that only the covering test has, around an argument that carries no name, such
as
foo(f())covered byfoo(f() . 'a'). Like a name that only the covering test has, it does not turn the candidate intoreview. - A spread that only the covering test has, around an argument of an assertion that carries no name, such as
expect(f())->toBe(1)covered byexpect(...f())->toBe(1), or$this->assertSame(1, f())covered by$this->assertSame(1, ...f()). The code under test gets the same input in both tests, so the candidate isdelete. - An operator that is not in the list above, such as
instanceof, because it carries no token. A spread inside a list is not the spread of an argument and carries no token either, sofoo([...$x, ...$y])againstfoo([...$y, ...$x])isdelete.
Each variable or property that is a key or a value of an array that a call or an assertion receives, at any depth, carries the position of that argument too, and so does the variable that an array element read holds, such as
$rowin$row['email']. Such a name has the prefixelement:, because the argument holds the variable and is not the variable. Before the prefix, the name carries its place inside the argument: the key of each array it sits under, written as in the path of a value (see Array keys and values), orvalue:list-itemfor an element of a list. A key, and the variable that an element read holds, take the place of their array or element read.toBe($rows[0]),toBe([$x]),toBe([[$x]])andtoBe(['a' => $x])havein:expect:function:f/call:toBe#0/element:variable:rows,.../value:list-item/element:variable:x,.../value:list-item/value:list-item/element:variable:xand.../value:'a'/element:variable:x, whiletoBe($x)has.../variable:x. A key that is itself a variable or a property names the value under it, so[$k => $x]has.../element:variable:kand.../variable:k/element:variable:x. The prefixeselement:andoperand:combine in the order the name is reached, sotoBe([$x . $y])has.../value:list-item/operator:concat#0/element:operand:variable:xandtoBe($rows[0] . 'a')has.../operator:concat#0/operand:element:variable:rows. So a test that passes$xand a test that passes[$x]differ, and two tests that hold the same names at other places of one argument, such as['from' => $a, 'to' => $b]and['from' => $b, 'to' => $a], differ too: neither isdeleteagainst the other. Names that swap places inside a list, such as[$a, $b]and[$b, $a], differ by their order tokens (see Order of list elements).expect($response->updatedEmails())->toBe([$subscriber->email => ['field1' => 'value1']])hasin:expect:variable:response/call:updatedEmails/call:toBe#0/element:variable:subscriber/property:email. A candidate withtoBe([])is thenreview, notmerge-into-dataset, against that test, because a dataset row cannot hold the key. So two tests that pass or expect different variables or properties differ indiffersBy, and the candidate isreview. The name of a property names its object too when the object is a variable, a property or a class, so$account->nameand$user->namediffer,$this->shop->urlhasvariable:this/property:shop/property:url, and$class::$shophasvariable:class/property:shop. A property of an object that has no name, such as the result of a call or an element read in$a->get()->idor$a['x']->id, hasproperty:<name>alone. Sotransfer([$a->get()->id, $b->get()->id])againsttransfer([$b->get()->id, $a->get()->id])is stilldelete, astransfer([$a->get(), $b->get()])againsttransfer([$b->get(), $a->get()])is. Properties that swap places inside an operator, a ternary or an interpolated string, such astransfer($a->id . $b->id)againsttransfer($b->id . $a->id), differ by the place of each operand, and the candidate isreview. A property whose name is not written out, such as$a->$fieldor$a->{'id'}, has no name token, sotransfer($a->$field)againsttransfer($b->$field)isdeletetoo. A parameter of the test closure or test method carries no name, because each dataset row fills it with another value. A property of a parameter is still named with the parameter, e.g.variable:row/property:idfor$row->id. A variable that the test assigns once and passes on where it first reads it carries no name there, because that read is counted as the value the variable holds. A later read of the variable carries the name. A name that only the covering test has does not turn adeletecandidate intoreview: the candidate passes another value at the same position, or the position token already lists the argument. - An operator in no argument of a call or an assertion, such as one that a closure returns or one assigned to a
variable that the test passes on. It carries no token, so
-
Closure arguments. A closure or an arrow function that is an argument of a call, a
newexpression or an assertion carries the call and the position followed byclosure, next to the value it returns (see Call arguments):store(fn () => 1)hasin:function:store#0/closure. A closure inside an array carries the place of its key orvalue:list-itemand the prefixelement:, as a variable does, sostore(['k' => fn () => 1])hasin:function:store#0/value:'k'/element:closureandstore([fn () => 1])hasin:function:store#0/value:list-item/element:closure. A closure that another closure returns carries no token of its own. A closure is not data, because a dataset row cannot fill its slot with a literal. A candidate is thereforereview, and notmerge-into-dataset, when its covering test passes a closure that returns another value where the candidate passes a literal, at a place the token covers: an argument of a call, anewexpression or an assertion, an array key or a list item, or an operand. Such arefoo('a')againstfoo(fn () => 1)orfoo(function () { return 1; }),foo(['k' => 'a'])againstfoo(['k' => fn () => 1]),foo(['a'])againstfoo([fn () => 1]), ortoBe('a')againsttoBe(fn () => 1). The token of the closure is listed incoveringDiffersBy. Two tests that pass a closure at the same place carry the same token, so closures that return other values, such asfoo(fn () => 'a')againstfoo(fn () => 1), staymerge-into-dataset. A closure that a dataset row passes to a parameter of the test is no closure argument of the body: the type of the parameter decides whether a row takes the literal (see Dataset rows). Three shapes keep their verdict. A literal that equals the value the closure returns, under an array key or in an assertion argument, is stilldelete:foo(['k' => 1])againstfoo(['k' => fn () => 1]), ortoBe(1)againsttoBe(fn () => 1). A closure in amatcharm carries no token, sofoo(match ($x) { 1 => 'a' })againstfoo(match ($x) { 1 => fn () => 1 })is stillmerge-into-dataset. The same holds for a candidate whosematcharm holds the closure:toBe(match ($x) { 1 => fn () => 1 })againsttoBe(match ($x) { 1 => 1 })is stilldelete, and withfn () => 2it is stillmerge-into-dataset. Two shapes arereview, and no longerdeleteormerge-into-dataset. A candidate that passes a closure where the covering test passes a literal under an array key or in an assertion argument isreview, whether the literal equals the value the closure returns or not:foo(['k' => fn () => 1])againstfoo(['k' => 1]), ortoBe(fn () => 2)againsttoBe(1). The same holds as an operand:foo($a ?? fn () => 2)againstfoo($a ?? 1). An argument of an assertion that holds a closure and no literal keeps its source code in abound:token, wherever the closure sits in the argument. Soexpect(g($a))->toBe($c ?? fn () => foo()); expect(g($b))->toBe($c ?? fn () => bar());against the same pair withfoo()andbar()swapped isreview. -
Assertion reads. A call that sits directly in an argument of an assertion, or in the receiver chain of that argument, such as
expect(activeState())orassertSame(5, $order->total()), adds the tokenread:followed by its own token, such asread:function:activeState, beside the token itself. The token counts how many of the calls are such reads, so a difference can tell a call the covering test arranges from a call it only reads in an assertion. A call inside another call that the assertion receives, or inside a closure, is not a read. Aread:token is never listed indiffersByorcoveringDiffersBy. -
Enclosing calls. A value that an argument passes on through an array or a nested call carries every enclosing call and array key, outermost first.
json_encode(json_encode(['test' => true]))has the tokenin:function:json_encode#0/function:json_encode#0/value:'test'/value:true, andjson_encode(['test' => true])hasin:function:json_encode#0/value:'test'/value:true, so a value passed through one more call is a different token. An assertion, such as a Pest expectation (expect,toBe) or a PHPUnitassert*orexpect*call, is left out of the path of a value that reaches it through another call:expect(add(1, 2))has the tokenin:function:add#0/value:1, the same as$sum = add(1, 2). An operand of an operator, a cast or a closure body keeps no call path in its own token, except for the value that a closure passed to a call returns (see Call arguments):store(1 + 2)has the tokensvalue:1andvalue:2. An operator in an argument adds the place of the argument instead: the operator carriesin:function:store#0/operator:plus, and each literal operand carries its place in the operator,in:function:store#0/operator:plus#0/value:1andin:function:store#0/operator:plus#1/value:2(see Argument names). The literal part of an interpolated string keeps no call path in its own token either:store("tag_{$name}")has the tokenvalue:'tag_'and, at its place,in:function:store#0/operator:interpolatedstring#0/value:'tag_'. A list item keeps the call path as a direct item of an argument and as an item of a list under a key:store([1])has the tokenin:function:store#0/value:1, andstore(['tags' => ['a']])hasin:function:store#0/value:'tags'/list/value:'a', where thelistsegment marks the list under the key. A list inside a list keeps no call path:store([[1]])hasvalue:1. Sostore([[1]])does not get the token ofstore([1])orstore(1), andstore(['tags' => ['a']])does not get the token ofstore(['tags' => 'a']). An item of a list under a key of an array no call receives keeps the key path with thelistsegment:$options = ['tags' => ['a']]hasin:value:'tags'/list/value:'a'. A list passed straight to a call keeps the token of its element:store([1])has the token ofstore(1)and the token of the list (see Lists). -
Dataset rows. A dataset row value is the value of the parameter at the same position of the test closure or method. A literal, constant, empty array, list or map in a row is counted where the test reads that parameter: the row
[1, 2, 3]ofexpect(add($first, $second))->toBe($sum)has the same tokens asexpect(add(1, 2))->toBe(3), the row[['id', 'tags']]ofload($columns)has the same tokens asload(['id', 'tags']), and the row[['id' => 5]]ofload($options)has the same tokens asload(['id' => 5]). A list is a non-empty array whose elements have no key and are each a literal, constant, empty array, list or map. A map is a non-empty array whose elements all have a key that is a literal or constant, and each value is a literal, constant, empty array, list or map, so lists and maps can nest. Any other row value, such asnew Contact()or['id' => $id], is counted once, as the row has it, however many times the test reads the parameter. A row value whose parameter the test never reads by its name keeps its token without a path. A closure or arrow function that is the row value, or one argument of the row, also carries the tokenvalue:closure:<hash>, a hash of its source asnikic/php-parserprints it. Two rows whose closures have different bodies, such asfn (string $email): string => $emailandfn (string $email): string => rawurlencode($email), therefore differ indiffersBy, and the row is notdelete. Two closures that differ only in whitespace or line breaks have the same token. A closure inside an array argument, such as['map' => fn ($x) => $x], carries only the tokens of its body. A test that combines several datasets, through several arguments of one->with()or through chained->with()calls, gets one row for each combination, as Pest runs it: the values of the rows follow one another in the order of the datasets, and the row name is the one Pest reports, such asplain" / dataset "slashfor the rows'plain'and'slash', or('ab') / dataset "slashfor a row without a key. When a row of one dataset cannot be named, its combinations stay unresolved. -
Local variables. A literal, constant, empty array, list or map (as defined for dataset rows) that the test assigns once to a local variable in its own statements is counted where the test reads the variable:
$email = 'a@example.com'; notify($email);has the same tokens asnotify('a@example.com'),$columns = ['id', 'tags']; load($columns);has the same tokens asload(['id', 'tags']), and$options = ['enabled' => false]; store($options);has the same tokens asstore(['enabled' => false]). A variable the test writes more than once keeps its tokens without a path. A call, function call ornewexpression that the test assigns once to a local variable is counted once, where the test first reads the variable, when that read passes the variable on as a call argument or an array value outside a loop body:$email = sprintf('email@%s', $domain); notify($email);has the same tokens asnotify(sprintf('email@%s', $domain)), and a later read of the variable adds no token. A variable whose first read does not pass it on, such as$user->email, or whose first read is in a loop body keeps the tokens of the call without a path.
How it works
- One test run with
--coverage-phpand--log-junitrecords which test case — every dataset row separately — covers which source line. Test descriptions come from the JUnit log. - A greedy pass removes test cases whose lines the remaining test cases still cover. On a tie it removes standalone tests before dataset rows.
- For each candidate it picks a small covering set greedily and compares contract fingerprints built with
nikic/php-parser: Pestit()/test()with->with()datasets and chained modifiers such as->throws(), and PHPUnittest*/#[Test]/@testmethods with#[DataProvider]/@dataProviderrows. Pest tests inside a namespace ordescribe()blocks, and shareddataset('name', …)rows of the same file, are parsed too.
Development
Contributing to this repository needs PHP 8.4+ with pcov.
composer install
composer test
| Command | What it does |
|---|---|
composer check |
Composer normalize, PHPCS, Pint, Rector, PHPStan, security audit, and the tests with 100% coverage |
composer fix |
Composer normalize, Rector, Pint and PHPCS fixes |
composer test |
The tests without coverage |
composer test:coverage |
The tests with a 100% coverage minimum |
composer ai-olympus:install |
Reinstall the pekral/ai-olympus agent rules and skills |
PHPCS, PHPStan and Rector use pekral/phpcs-rules, pekral/phpstan-rules and pekral/rector-rules.
CI
Pull requests and pushes to master run composer check on PHP 8.4 and 8.5, then the tool on its own tests. A matrix
runs the tests on PHP 8.4 and 8.5 with the lowest and the highest dependencies, and a second matrix runs the tool on
the PHPUnit fixture with PHPUnit 10.5, 11.5, 12.5 and 13.4 without Pest and paratest, on PHP 8.4. A security audit runs every
Monday, and Dependabot proposes Composer and GitHub Actions updates weekly.
Changelog · Contributing · Security · License
- CHANGELOG
- CONTRIBUTING
- Security policy
- MIT — see LICENSE
Related Packages
Framework-agnostic test traceability for PHP. Supports Pest and PHPUnit with bid...
Version History
| Version | Released | PHP | License |
|---|---|---|---|
| 0.2.0 | ^8.4 | MIT | |
| 0.1.10 | ^8.4 | MIT | |
| 0.1.9 | ^8.4 | MIT | |
| 0.1.8 | ^8.4 | MIT | |
| 0.1.7 | ^8.4 | MIT | |
| 0.1.6 | ^8.4 | MIT | |
| 0.1.5 | ^8.4 | MIT | |
| 0.1.4 | ^8.4 | MIT | |
| 0.1.3 | ^8.4 | MIT | |
| 0.1.2 | ^8.4 | MIT | |
| 0.1.1 | ^8.4 | MIT | |
| 0.1 | ^8.4 | MIT |