Turborepo Testing Guide: Running Tests Only for Changed Packages
Turborepo's core promise is simple: run tasks in the order they need to run, skip work you've already done, and do as much in parallel as possible. For testing, this translates to a system where only the packages affected by your changes get tested, previously-passing test results are cached and replayed instantly, and CI pipelines that used to take 20 minutes run in under 5.
This guide covers how to configure Turborepo's test pipeline correctly, how to use --filter to scope test runs, how remote caching works for test artifacts, and how to wire it all up in GitHub Actions.
How Turborepo Thinks About Tasks
Before configuring the test pipeline, it helps to understand Turborepo's execution model. Every task in Turbo runs at the package level. When you run turbo run test, Turbo:
- Reads
turbo.jsonto understand whattestdepends on - Resolves the workspace graph from
package.jsondependencies - Determines which packages need to re-run
testbased on changed inputs - Schedules task execution, respecting dependencies and maximizing parallelism
The key insight is that Turbo's workspace graph comes from your package.json dependencies — it doesn't need a separate graph file. If app-a depends on lib-b, Turbo knows that changing lib-b means app-a's test task needs to run.
Configuring the Test Pipeline in turbo.json
A minimal pipeline configuration for tests:
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"test": {
"dependsOn": ["^build"],
"inputs": [
"src/**/*.ts",
"src/**/*.tsx",
"test/**/*.ts",
"jest.config.*",
"vitest.config.*",
"tsconfig.json"
],
"outputs": ["coverage/**", "test-results/**"],
"cache": true
},
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"]
}
}
}Breaking down the key fields:
dependsOn: ["^build"] — The ^ prefix means "build must complete in all dependencies before test runs in this package." If app-a depends on lib-b, lib-b must build before app-a's tests run. This ensures your tests always run against up-to-date compiled output.
inputs — The file glob patterns that, when changed, invalidate the cache. Be specific here. Including **/* would invalidate on any file change, including README updates that don't affect test behavior.
outputs — Coverage reports and test result artifacts. Turbo can restore these from cache alongside the task result, so your CI can upload coverage reports even when the test itself was cached.
cache: true — Enables caching for this task. This is the default but worth being explicit about.
When Tests Don't Depend on Build
For packages with pure TypeScript tests that don't need compiled output:
{
"pipeline": {
"test": {
"dependsOn": [],
"inputs": ["src/**/*.ts", "test/**/*.ts", "jest.config.ts"],
"outputs": ["coverage/**"],
"cache": true
}
}
}Removing the build dependency makes the pipeline faster — Turbo can run tests immediately without waiting for downstream builds to complete.
Running Tests with --filter
The --filter flag is Turborepo's mechanism for scoping task execution to a subset of the workspace.
Filter by Package Name
# Test a specific package
turbo run test --filter=@myorg/ui
# Test multiple packages
turbo run test --filter=@myorg/ui --filter=@myorg/apiFilter by Changed Packages
This is where the real efficiency comes from:
# Test packages changed since branching from main
turbo run test --filter=...[origin/main]
# Test packages changed in the last commit
turbo run test --filter=...[HEAD^1]
# Test packages changed between two specific commits
turbo run test --filter=...[abc123...def456]The ... prefix means "this package and all packages that depend on it." So --filter=...[origin/main] means: "find everything that changed since main, then also include anything that depends on those changed packages." This is the transitive dependency expansion you need for correctness.
Filter by Directory
# Test all packages under apps/
turbo run test --filter="./apps/*"
# Test all packages under libs/
turbo run test --filter="./libs/*"Combining Filters
# Changed packages in apps/ only
turbo run test --filter="./apps/*[origin/main]"This is useful in monorepos where you want to separate app tests from library tests in different CI jobs.
Remote Caching for Test Results
Local caching helps individual developers avoid re-running tests they've already passed locally. Remote caching extends this to the whole team and CI infrastructure — when CI caches a test result, your next local turbo run test can replay from that cache instead of running the suite.
Setting Up Vercel Remote Cache
The default remote cache backend is Vercel:
npx turbo login
npx turbo linkThis generates a TURBO_TOKEN and TURBO_TEAM that you store as CI secrets. Every Turbo run then checks the remote cache before executing tasks.
Setting Up a Self-Hosted Cache
For teams that can't use Vercel's cloud, there are self-hosted backends. The turborepo-remote-cache package is a popular open-source option:
npm install -g turborepo-remote-cache
turbo-cache-serverConfigure it in your environment:
TURBO_API=http://your-cache-server:3000
TURBO_TOKEN=your-secret-token
TURBO_TEAM=your-team-namePass these to turbo run and it will use your server as the cache backend.
Understanding Cache Keys
Turbo computes a cache key from:
- The hash of all
inputsfiles - The hash of the task configuration
- Environment variables listed in
env(if configured) - The workspace dependency graph
If any of these change, the cache is invalidated. To include environment variables in the cache key (useful if tests behave differently based on env):
{
"pipeline": {
"test": {
"env": ["NODE_ENV", "DATABASE_URL"],
"inputs": ["src/**/*.ts"],
"cache": true
}
}
}Be careful with this — adding unstable env vars (like timestamps or build IDs) to env will defeat caching.
Integrating with GitHub Actions
A complete workflow for PR testing with Turbo:
name: Test
on:
pull_request:
push:
branches: [main]
jobs:
test:
name: Test changed packages
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history needed for --filter=[origin/main]
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run tests for changed packages
run: pnpm turbo run test --filter=...[origin/main] --parallel
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
- name: Upload coverage reports
uses: codecov/codecov-action@v4
with:
directory: ./coverage
flags: unittestsHandling the Base Branch in PRs
For pull requests, origin/main might not be the right base. Use github.event.pull_request.base.sha for accuracy:
- name: Run tests for changed packages
run: |
BASE_SHA="${{ github.event.pull_request.base.sha || 'origin/main' }}"
pnpm turbo run test --filter=...[$BASE_SHA] --parallel
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}This uses the actual merge base for the PR, which is more accurate than origin/main when the main branch has moved ahead since the PR branched off.
Separate Jobs for Unit and E2E Tests
For repos where E2E tests require a running server:
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run test:unit --filter=...[origin/main]
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
e2e-tests:
runs-on: ubuntu-latest
needs: unit-tests
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run build --filter=...[origin/main]
- run: pnpm turbo run test:e2e --filter=...[origin/main] --no-cache
# --no-cache for E2E because browser state and network are external inputs
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}Note --no-cache for E2E tests. When tests interact with databases, browser state, or external APIs, caching the result can give you a false green — the cached result says "passed" but the system state may have changed.
Workspace Dependency Tracking
The accuracy of Turbo's change detection depends entirely on your workspace's package.json declarations being accurate.
Internal Dependencies
{
"name": "@myorg/checkout",
"dependencies": {
"@myorg/ui": "workspace:*",
"@myorg/api-client": "workspace:*"
}
}When @myorg/ui changes, Turbo knows @myorg/checkout is also affected. If this dependency is missing from package.json, Turbo will miss the connection and skip @myorg/checkout's tests.
Validating Your Dependency Graph
Turbo provides a way to visualize the graph:
turbo run test --graph
turbo run test --graph=graph.dotThe --graph flag outputs a Graphviz DOT file you can visualize. Run this periodically to verify the graph matches your mental model of the workspace.
Handling Shared Config Files
Root-level config files like jest.config.base.js or .eslintrc.json affect every package but aren't captured in the workspace graph. Add them to the global inputs in turbo.json:
{
"globalDependencies": [
"jest.config.base.js",
".eslintrc.json",
"tsconfig.base.json"
]
}When any of these change, the cache is invalidated for every package — which is the correct behavior since these files affect all tests.
Handling Test Artifacts
Test coverage reports, JUnit XML files, and screenshots need special handling in a cached environment.
Restoring Artifacts from Cache
Configure outputs in the pipeline to include artifact directories:
{
"pipeline": {
"test": {
"outputs": ["coverage/**", "test-results/**", "playwright-report/**"]
}
}
}When a test run is replayed from cache, Turbo restores these directories so downstream steps (like coverage upload) work correctly.
Merging Coverage from Multiple Packages
When testing multiple packages in parallel, each generates its own coverage directory. To get a unified coverage report:
# Install nyc or c8 at the root
npm install -D nyc
# Merge coverage after turbo run
npx nyc merge coverage merged-coverage.json
npx nyc report --reporter=lcov --temp-dir=merged-coverage.jsonOr use a workspace-aware coverage tool like vitest's built-in merge support.
Monitoring What's Being Skipped
One risk with smart test selection is silent regressions: a bug exists in a package, but that package never gets tested because its inputs files haven't changed. If your test inputs don't capture all the ways the package's behavior can change — environment variables, external schema changes, transitive config updates — you can get false confidence.
Running a full test suite periodically (nightly on main, or before releases) catches regressions that filtered PR runs might miss. Tools like HelpMeTest can layer on top of your Turbo setup to track test health trends and flag when packages haven't been tested recently — giving you confidence that smart filtering isn't leaving blind spots.
Summary
Turborepo makes monorepo testing efficient by combining workspace graph analysis, fine-grained input tracking, and remote cache sharing. The key steps:
- Configure the test pipeline in
turbo.jsonwith accurateinputsandoutputs - Use
--filter=...[origin/main]to scope test runs to changed packages - Set up remote caching so CI runs benefit from each other's results
- Add
globalDependenciesfor root-level config files that affect all packages - Use
--no-cacheselectively for tests that have external state (E2E, integration)
With this setup, a PR that touches one library in a 50-package monorepo runs that library's tests plus the tests of anything that depends on it — and nothing else. That's the feedback speed that keeps large teams moving fast.