gosuperscript / phpunit-tia
Test Impact Analysis for PHPUnit: record a test-to-file dependency graph, then re-run only the tests affected by your changes.
Requires
- php: ^8.2
- phpunit/php-code-coverage: ^12.0
- phpunit/phpunit: ^12.0
Requires (Dev)
- pestphp/pest: ^4.7
This package is auto-updated.
Last update: 2026-08-03 20:10:27 UTC
README
Test Impact Analysis for PHPUnit and Pest — run only the tests affected by your latest changes, inspired by Pest 5's Tia Engine, for projects that are not on Pest 5.
Register the extension once and keep running your usual command:
$ vendor/bin/phpunit
tia ▸ no baseline yet; recording one from this run
OK (412 tests, 1209 assertions)
$ vendor/bin/phpunit # nothing changed
tia ▸ 0 of 87 known test files affected; 87 will be skipped
tia ▸ skipped 398 tests unaffected by your changes.
OK, but some tests were skipped!
$ vim src/Invoice/TaxCalculator.php
$ vendor/bin/phpunit
tia ▸ 3 of 87 known test files affected; 84 will be skipped
tia ▸ skipped 384 tests unaffected by your changes.
OK, but some tests were skipped!
How it works
- Record. The first run builds a dependency graph — test file → source
files it executed — from PHPUnit's own per-test code coverage. Nothing is
instrumented twice, and
--coveragereports still behave normally. The graph lives in.phpunit-tia/(auto-gitignored). - Diff. Every subsequent run hashes your watched files and compares against the last green run. PHP files are hashed with comments, docblocks, and whitespace stripped — a comment edit or formatter pass changes nothing.
- Skip. Changed source files map back through the graph to the test files that cover them; changed test files select themselves. Everything else is marked skipped before it does any work, and re-recorded when it next runs.
File hashes are only saved after a green run, so failing tests stay selected until they pass.
When it can't prove anything, it runs everything
Borrowed straight from Pest's Tia Engine rules:
- Structural files —
composer.json,composer.lock,phpunit.xml*,.env*,docker-compose.yml— force a full re-record. - Unprovable directories —
config/,routes/,database/,resources/are loaded dynamically, so any change there runs the full suite. - Test support files — non-
*Test.phpfiles under your test directories (base TestCases, helpers, fixtures) force a full run. - New source files aren't in the graph yet, so they force a full run.
- A PHP version change invalidates the baseline.
Installation
composer require --dev gosuperscript/phpunit-tia
Requires PHP ≥ 8.2, PHPUnit ^12 (Pest 4 included), and PCOV or Xdebug for
recording runs. Your phpunit.xml must have <source><include> paths
configured — that is what defines which files the graph tracks.
1. Register the extension
<extensions> <bootstrap class="Superscript\PhpunitTia\Extension\TiaExtension"/> </extensions>
2. Let your test cases skip themselves
PHPUnit's extension API is observe-only — an extension cannot deselect a test — so the decision is applied by a trait on your base test case:
use Superscript\PhpunitTia\SkipsUnaffectedTests; abstract class TestCase extends \PHPUnit\Framework\TestCase { use SkipsUnaffectedTests; protected function setUp(): void { $this->skipIfUnaffected(); // ← first statement parent::setUp(); } }
The explicit call matters. The trait also registers a #[Before] hook, but
PHPUnit deliberately runs the remaining before-hooks — setUp() among them —
even after one signals a skip, and only reports the skip afterwards. Without
the call at the top of setUp(), unaffected tests would still boot your
framework, open transactions and seed databases: most of the cost. Test cases
with no meaningful setUp() need no edit; the hook covers them.
For Pest, one line in tests/Pest.php applies it to every test file:
uses(Superscript\PhpunitTia\SkipsUnaffectedTests::class)->in(__DIR__);
Pest compiles some test files into classes via eval(); those are resolved
back to their real paths, so selection works the same way.
3. Make sure the coverage driver sees your sources
If you use PCOV, pcov.directory must be set and must contain every
<source> path. Unset, PCOV guesses from load order, and that guess can
silently yield no coverage at all — so phpunit-tia refuses to persist a graph
in that case rather than record a partial one that would skip tests it should
not. Set it in php.ini, per invocation (php -d pcov.directory=.), or in CI:
- uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: pcov ini-values: pcov.directory=.
Xdebug in coverage mode has no such restriction.
Modes
Set TIA_MODE to change behaviour:
| Value | Effect |
|---|---|
(unset) / select |
Skip unaffected test files, re-record the ones that run |
record |
Run everything and replace the baseline |
collect |
Run everything, merge into the baseline, skip nothing |
off |
Disable entirely |
Alternative: the launcher
vendor/bin/phpunit-tia wraps your runner instead of relying on the trait. It
computes the affected set up front and invokes PHPUnit with only those files,
so unaffected test files are never even loaded — faster than skipping, and it
needs no changes to your test cases. It also sets pcov.directory for you.
vendor/bin/phpunit-tia [phpunit options]
| Flag | Effect |
|---|---|
--tia-dry-run |
Print the affected test files without running them |
--tia-full |
Discard the baseline and re-record from scratch |
--tia-fetch |
Download the newest CI baseline artifact (via gh) when no local baseline exists |
--tia-baseline |
Print the baseline storage path (for CI artifact upload) |
Trade-offs: the launcher is a separate command and its reporting covers only the tests that ran (plus a replay summary), whereas the extension keeps your existing command and reports skips inline in the normal PHPUnit/Pest output.
CI: baseline sharing
The same model as Pest's Tia Engine: one workflow pays the full instrumented cost per merge to the default branch and publishes the baseline as an artifact; every other run fetches it and only executes the affected slice.
# .github/workflows/tia-baseline.yml — records the baseline once per merge name: tia-baseline on: push: { branches: [main] } schedule: [{ cron: '0 3 * * *' }] workflow_dispatch: jobs: baseline: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: shivammathur/setup-php@v2 with: { php-version: '8.4', coverage: pcov } - run: composer install --no-interaction - run: vendor/bin/phpunit-tia --tia-full - uses: actions/upload-artifact@v4 with: name: phpunit-tia-baseline path: .phpunit-tia/
In the test job of your regular CI workflow:
- run: vendor/bin/phpunit-tia --tia-fetch env: GH_TOKEN: ${{ github.token }}
--tia-fetch uses the GitHub CLI (preinstalled on GitHub runners) to grab
the newest successful tia-baseline.yml artifact; override the workflow or
artifact name with TIA_WORKFLOW / TIA_ARTIFACT. If fetching fails for
any reason, the run falls back to recording a local baseline — always
correct, just not fast.
After a selective run, the launcher reports the combined signal:
tia ▸ 3 of 87 test files affected.
OK (14 tests, 41 assertions)
tia ▸ replayed 84 unaffected test files from baseline: 398 tests, 1168 assertions, all green (~6m12s of execution avoided).
Replayed entries come from the per-test-file results cache recorded with the graph. Only files that were green at recording time and whose dependencies are unchanged are replayed, and the exit code always comes from the tests that actually ran. PHP version compatibility between CI and local machines is checked at major.minor level, so a patch-version difference doesn't invalidate a fetched baseline.
The launcher also works with Pest — point it at the Pest binary:
TIA_PHPUNIT=vendor/bin/pest vendor/bin/phpunit-tia
Caveats
- Selection is per test file, not per test method.
- In extension mode, unaffected tests are reported as PHPUnit skips (
S), so a suite running--fail-on-skippedneeds that flag dropped or the launcher instead. Skipped tests still appear in the test count. - Replay in launcher mode is a summary, not a full report reconstruction: unaffected tests are reported as aggregate counts on stderr, and the exit code covers the tests that actually ran. Machine-readable full-suite reports (merged JUnit XML, replayed coverage) are not built yet.
- Extension mode collects coverage on every run to keep the graph fresh. That is a modest constant cost (PCOV) but not free.
- Narrowing flags (
--filter,--group,--testsuite, ...) work, but such runs merge into the graph rather than replacing it. - Dynamically loaded code that coverage can't see (values read from config arrays, container definitions resolved at runtime) is handled by the conservative directory rules above, not by real dependency tracking.
- Running with both PCOV and Xdebug loaded works; the driver is chosen by
php-code-coverage's selector.
Roadmap
- Method-level selection granularity
- Merged JUnit XML output (full-suite reports for CI consumers)
- Coverage replay (report baseline coverage for unaffected tests)
- PHPUnit 13 support
- Framework-aware rules (Blade → compiled view mapping, Vite module graph)