Test Impact Analysis (TIA)
Heads up — please keep this between us. Test Impact Analysis has shipped quietly in Pest 4 so a small group of teams can battle-test it on real suites before we put it on stage. The official, public launch is reserved for Pest 5.
If you're reading this, you're getting an early look as a thank-you for being part of the Pest community. We'd love your feedback, bug reports, and edge cases — but we kindly ask that you don't share this publicly (no tweets, blog posts, livestreams, or conference demos) until the Pest 5 announcement. Internal use within your team is absolutely fine.
Thanks for helping us get this right.
Test Impact Analysis is a great way to drastically reduce the time it takes to run your test suite by re-running only the tests affected by your latest changes. The first time you run with --tia, Pest records a graph of which tests depend on which files. Every run after that, Pest looks at what you changed, runs only the tests that touched those files, and replays cached results for everything else.
A typical Laravel suite that takes 15 seconds replays in under a second. Edits to a single Blade template re-run a handful of feature tests. Comment-only edits, formatter passes, and README touches re-run nothing at all.
To get started, just add the --tia flag to any Pest invocation:
1./vendor/bin/pest --parallel --tia
The first run is the baseline — Pest enables a coverage driver (PCOV or Xdebug) and records the dependency graph as your tests execute. Expect a small overhead on this run only.
Every subsequent run is a replay. Pest compares your working tree against the baseline and re-runs only the tests affected by your changes:
1Tests: 774 passed (2658 assertions, 7 affected, 2 uncached, 765 replayed)2Duration: 0.74s
affected is the set of tests Pest re-ran because their dependencies changed. uncached means Pest had to execute a test because no cached result existed yet. replayed is the set whose results were served from cache.
How Pest Decides What To Run
For each file you've changed, Pest looks for tests that depend on it:
- PHP source files — your
app/classes, controllers, models, helpers — are tracked through the coverage driver. A change toapp/Models/User.phpre-runs only the tests that touchedUser. - Migrations are intersected with the tables each test queried during the baseline. A column rename in
create_users_table.phpre-runs only the tests that queried theuserstable. - Inertia pages under
resources/js/Pagesre-run only the tests that server-side-rendered them. - Shared JS components under
resources/js/Components,Layouts, and friends re-run only the tests whose pages import them — Pest walks Vite's module graph to figure this out. - Frontend runtime files like
resources/js/App.jsx,resources/js/bootstrap.js,resources/js/echo.js, andresources/js/favicon.jsre-run tests that rendered Inertia components, because they can affect the whole client runtime. - Blade templates re-run only the tests that rendered them, including renders triggered by browser tests.
- Arch tests re-run for project PHP source changes, because Arch expectations inspect files by namespace and path instead of executing those files.
- Browser assets such as CSS, public build files, static public assets, and
public/hotre-run browser tests only. - Anything else — config files, route files, fixture data, files outside the recorded graph — falls through to a broader pattern. Editing
config/app.phpre-runs every test, because Pest can't statically prove which tests depend on it.
Some files change the shape of the graph itself rather than a single test result. Pest rebuilds the graph when structural inputs drift, including composer.json, composer.lock, phpunit.xml, vite.config.*, package.json, Node lockfiles, and tsconfig / jsconfig files. Environment files such as .env, .env.testing, and local variants drop cached results and re-execute the suite while keeping the graph.
Cosmetic Edits Don't Run Anything
Pest normalises file content before comparing, so cosmetic changes don't trigger any tests. PHP files have whitespace, line comments, and docblocks stripped before hashing. Blade strips {{-- … --}} comments. JS, TS, Vue, and Svelte files have line and block comments removed too.
The result: a comment-only edit, a Prettier reformat, a Pint pass, or a README tweak produces an identical hash, and the file never enters the changed set. Zero tests run.
Built-in Environments
Pest ships with watch defaults for the most common PHP stacks, and applies them automatically when their packages are installed:
- PHP — always-on baseline rules for
composer.json,composer.lock,phpunit.xml, and similar structural inputs. - Laravel —
app/,routes/,config/,database/migrations/,resources/views/, and friends. - Symfony —
config/,migrations/,src/Migrations/,templates/,translations/,config/doctrine/,assets/,webpack.config.js, andimportmap.php. - Livewire —
resources/views/livewire/,resources/views/components/,resources/views/pages/, plus JS/TS underresources/js/. - Inertia — server-side-rendered pages under
resources/js/Pagesand the Vite module graph forComponents,Layouts, and runtime entry files. - Browser — CSS, public build files, static public assets, and
public/hotre-run browser tests only.
You don't have to configure anything to opt in to these — Pest detects each framework via Composer and merges the relevant rules. To extend or override them, see Custom Watch Patterns.
Modes
Pest supports a few flags alongside --tia:
| Flag | Behaviour |
|---|---|
--tia |
Replay if a baseline graph exists, otherwise record. |
--no-tia |
Disable TIA for this run, even if pest()->tia()->always() is configured. |
--tia --fresh |
Discard any existing graph and re-record from scratch. Use this after large refactors or when the graph feels stale. |
--tia --refetch |
Force a CI baseline fetch even within the 24-hour cooldown after a previous failed fetch. |
--tia --filtered |
Narrow PHPUnit to only the affected test files rather than loading all tests and replaying cached results for unaffected ones. |
--tia --locally |
Equivalent to pest()->tia()->always()->locally() — run TIA automatically on local machines but skip on CI. |
--tia --baselined |
Opt in to fetching the shared baseline from CI when no local graph exists or the local graph drifts. |
--baseline |
Print the absolute path of this project's TIA storage directory and exit. Designed for CI uploads — see Sharing The Baseline From CI. |
Environment Variables
Each enabling flag has an environment variable equivalent, useful for CI matrices, container entry points, and shared developer configs:
| Variable | Equivalent flag |
|---|---|
PEST_TIA=1 |
--tia |
PEST_TIA_FILTERED=1 |
--filtered |
PEST_TIA_LOCALLY=1 |
--locally |
PEST_TIA_BASELINED=1 |
--baselined |
Sharing The Baseline From CI
Recording the baseline locally takes minutes on large suites. Instead, you can have CI record it once per merge to main, and every developer downloads the result.
Baseline fetching is opt-in. Enable it either with --tia --baselined on the command line, the PEST_TIA_BASELINED=1 environment variable, or — preferred for teams — by calling pest()->tia()->baselined() in tests/Pest.php. Once enabled, when Pest detects no local graph (or the local graph is out of date) it uses GitHub's CLI to download the latest successful run of a tia-baseline.yml workflow's pest-tia-baseline artifact. Pest validates the fetched graph against your project state — if it matches, it's adopted. Otherwise it's discarded and a local rebuild proceeds.
Here's a starter workflow you can drop into .github/workflows/tia-baseline.yml:
1name: TIA Baseline 2on: 3 push: { branches: [main] } 4 schedule: [{ cron: '0 3 * * *' }] 5 workflow_dispatch: 6jobs: 7 baseline: 8 runs-on: ubuntu-latest 9 steps:10 - uses: actions/checkout@v411 with: { fetch-depth: 0 }12 - uses: shivammathur/setup-php@v213 with: { php-version: '8.4', coverage: xdebug }14 - run: composer install --no-interaction --prefer-dist15 16 - name: Run tests17 run: ./vendor/bin/pest --parallel --tia --coverage18 19 - name: Resolve TIA baseline path20 id: baseline21 run: echo "path=$(vendor/bin/pest --baseline)" >> "$GITHUB_OUTPUT"22 23 - name: Upload TIA baseline24 uses: actions/upload-artifact@v425 with:26 name: pest-tia-baseline27 path: ${{ steps.baseline.outputs.path }}28 include-hidden-files: true29 retention-days: 30
vendor/bin/pest --baseline prints the absolute path to this project's TIA storage directory (typically ~/.pest/tia/<project-key>/), which is exactly what actions/upload-artifact needs to bundle the recorded graph and coverage cache. include-hidden-files: true is required because the baseline lives under a dot-prefixed directory.
After CI runs, every developer with baselined() enabled who runs pest --tia for the first time on the repo will download this baseline and start replaying immediately, paying no record cost.
Storage
Pest stores its state at ~/.pest/tia/<project-key>/, where the project key is derived from your normalised git remote URL — so git@github.com:foo/bar.git and https://github.com/foo/bar produce the same key. A non-git project falls back to a hash of the project's absolute path.
Sharing state per remote URL means multiple worktrees of the same repository share one cache, while unrelated projects on the same machine stay isolated.
Configuration
You can configure TIA behaviour in tests/Pest.php via pest()->tia():
1pest()->tia()2 ->always() // run TIA on every invocation, no --tia flag needed3 ->locally() // restrict always() to local environments only4 ->baselined() // fetch the shared baseline from CI when no local graph exists5 ->filtered(); // narrow PHPUnit to only affected test files
always() activates TIA for every pest run without requiring the --tia flag. Pair it with locally() to restrict that behaviour to local machines — on CI (detected via the --ci flag or the CI environment variable) TIA is skipped automatically. An explicit --tia on the command line always takes effect regardless, and --no-tia can disable it for a single run.
filtered() enables filtered mode, equivalent to --tia --filtered. In this mode Pest narrows PHPUnit to only the affected test files rather than loading the full suite and replaying cached results for unaffected tests:
1pest()->tia()->filtered();
baselined() opts in to fetching the shared TIA baseline from CI when no local graph exists or the local graph drifts. See Sharing The Baseline From CI for the recommended workflow:
1pest()->tia()->baselined();
Custom Watch Patterns
If your project has a directory layout that doesn't match the framework defaults, you can register custom watch patterns in tests/Pest.php:
1pest()->tia()->watch([2 'config/billing/**/*.php' => 'tests/Feature/Billing',3 'public/build/**/*' => 'tests/Browser',4]);
Each glob maps to a test directory or an exact test file. Whenever a matching file changes, every test under that directory is invalidated. If a glob already exists in Pest's defaults, your target is merged with the existing targets instead of replacing them.
Now that you've learned how to use Test Impact Analysis to speed up your test suite, let's discuss how to integrate Pest with your continuous integration workflow: Continuous Integration