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 to app/Models/User.php re-runs only the tests that touched User.
  • Migrations are intersected with the tables each test queried during the baseline. A column rename in create_users_table.php re-runs only the tests that queried the users table.
  • Inertia pages under resources/js/Pages re-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, and resources/js/favicon.js re-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/hot re-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.php re-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.
  • Laravelapp/, routes/, config/, database/migrations/, resources/views/, and friends.
  • Symfonyconfig/, migrations/, src/Migrations/, templates/, translations/, config/doctrine/, assets/, webpack.config.js, and importmap.php.
  • Livewireresources/views/livewire/, resources/views/components/, resources/views/pages/, plus JS/TS under resources/js/.
  • Inertia — server-side-rendered pages under resources/js/Pages and the Vite module graph for Components, Layouts, and runtime entry files.
  • Browser — CSS, public build files, static public assets, and public/hot re-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@v4
11 with: { fetch-depth: 0 }
12 - uses: shivammathur/setup-php@v2
13 with: { php-version: '8.4', coverage: xdebug }
14 - run: composer install --no-interaction --prefer-dist
15 
16 - name: Run tests
17 run: ./vendor/bin/pest --parallel --tia --coverage
18 
19 - name: Resolve TIA baseline path
20 id: baseline
21 run: echo "path=$(vendor/bin/pest --baseline)" >> "$GITHUB_OUTPUT"
22 
23 - name: Upload TIA baseline
24 uses: actions/upload-artifact@v4
25 with:
26 name: pest-tia-baseline
27 path: ${{ steps.baseline.outputs.path }}
28 include-hidden-files: true
29 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 needed
3 ->locally() // restrict always() to local environments only
4 ->baselined() // fetch the shared baseline from CI when no local graph exists
5 ->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