Turborepo Testing: Remote Caching, Parallel Test Runs, and CI Integration
Turborepo is Vercel's high-performance build system for JavaScript and TypeScript monorepos. Its core value for testing is the same as for builds: never do the same work twice. With remote caching, a test that passed on your colleague's machine won't re-run in CI — the cached result is replayed instead.
This guide covers how to configure Turborepo for testing, set up remote caching, run tests in parallel, and integrate everything with CI.
How Turborepo Caching Works
Turborepo computes a hash for every task based on:
- The task's input files (controlled by
inputsinturbo.json) - The task's environment variables (controlled by
envinturbo.json) - The outputs of upstream tasks this task depends on
If the hash matches a cached result (locally or remotely), Turborepo replays the cached output and exits immediately. No test runner is invoked.
# First run — executes tests
pnpm turbo <span class="hljs-built_in">test
<span class="hljs-comment"># Second run — cache hit
pnpm turbo <span class="hljs-built_in">test
<span class="hljs-comment"># • Packages in scope: @myorg/ui, @myorg/api, @myorg/utils
<span class="hljs-comment"># • Running test in 3 packages
<span class="hljs-comment"># cache hit, replaying logs @myorg/utils:test
<span class="hljs-comment"># cache hit, replaying logs @myorg/ui:test
<span class="hljs-comment"># cache hit, replaying logs @myorg/api:testThe key insight: Turborepo caches at the package level, not the workspace level. Each package's test result is cached independently.
Pipeline Configuration
Configure the test pipeline in turbo.json at the workspace root:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"test": {
"dependsOn": ["^build"],
"inputs": [
"src/**/*.ts",
"src/**/*.tsx",
"test/**/*.ts",
"test/**/*.tsx",
"jest.config.*",
"vitest.config.*",
"package.json"
],
"outputs": ["coverage/**"],
"env": ["NODE_ENV", "CI"]
},
"test:unit": {
"inputs": ["src/**/*.ts", "src/**/*.tsx", "**/*.test.ts"],
"outputs": []
},
"lint": {
"inputs": ["**/*.ts", "**/*.tsx", ".eslintrc.*"]
}
}
}Key configuration decisions:
dependsOn: ["^build"]— test after dependencies are built. The^means upstream packages.inputs— files that invalidate the cache. Be explicit: omitting a file means changing it won't re-run tests (dangerous for config files).outputs— files to cache and restore. Coverage reports should be included.env— environment variables that affect test behavior.CI=trueoften changes test output and timeouts.
Running Tests
# Run tests across all packages
pnpm turbo <span class="hljs-built_in">test
<span class="hljs-comment"># Run tests for a specific package
pnpm turbo <span class="hljs-built_in">test --filter=@myorg/ui
<span class="hljs-comment"># Run tests for a package and everything that depends on it
pnpm turbo <span class="hljs-built_in">test --filter=...@myorg/ui
<span class="hljs-comment"># Run tests for changed packages only (compared to main)
pnpm turbo <span class="hljs-built_in">test --filter=[main]
<span class="hljs-comment"># Run a different task
pnpm turbo <span class="hljs-built_in">test:unitThe --filter flag is your primary tool for scoped execution. In CI, --filter=[origin/main] runs tests only for packages that changed relative to the base branch.
Remote Caching Setup
Local caching helps you personally. Remote caching shares the cache between all team members and CI runs.
Vercel Remote Cache
Turborepo integrates natively with Vercel:
# Link workspace to Vercel remote cache
pnpm turbo login
pnpm turbo <span class="hljs-built_in">linkAfter linking, Turborepo reads and writes to the Vercel cache automatically. A test that passes on a developer's machine will have its result available in CI — CI replays the cache instead of running tests.
Self-Hosted Remote Cache
For teams that can't use Vercel, open-source remote cache servers are available:
# Using ducktape (popular self-hosted option)
npx ducktape-serverConfigure the remote cache URL in turbo.json or via environment variables:
TURBO_TEAM=myorg
TURBO_TOKEN=your-token
TURBO_API=https://your-cache-server.comOr in turbo.json:
{
"remoteCache": {
"enabled": true,
"preflight": true
}
}Parallel Execution
Turborepo runs tasks in parallel by default, respecting the dependsOn graph. Control concurrency with --concurrency:
# Up to 10 tasks in parallel (default is number of CPUs)
pnpm turbo <span class="hljs-built_in">test --concurrency=10
<span class="hljs-comment"># Sequential (useful for debugging)
pnpm turbo <span class="hljs-built_in">test --concurrency=1
<span class="hljs-comment"># Percentage of CPU cores
pnpm turbo <span class="hljs-built_in">test --concurrency=50%Within a single package, tests still run using whatever parallelism the test runner (Jest, Vitest) provides. Turborepo's parallelism is across packages.
CI Integration — GitHub Actions
A complete CI workflow with remote caching:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: corepack enable
- run: pnpm install --frozen-lockfile
- name: Run tests (changed packages only on PR)
if: github.event_name == 'pull_request'
run: pnpm turbo test --filter=[origin/${{ github.base_ref }}]
- name: Run all tests (on main)
if: github.event_name == 'push'
run: pnpm turbo test
- name: Upload coverage reports
uses: codecov/codecov-action@v4
with:
directory: ./
flags: monorepoSet TURBO_TOKEN as a GitHub secret and TURBO_TEAM as a variable in your repository settings.
Dry Run and Debug
Before committing a pipeline config, verify what Turborepo will do:
# Show what would run without running it
pnpm turbo <span class="hljs-built_in">test --dry-run
<span class="hljs-comment"># Verbose output showing cache keys and reasons
pnpm turbo <span class="hljs-built_in">test --verbosity=2
<span class="hljs-comment"># Show the task graph
pnpm turbo <span class="hljs-built_in">test --graphThe --dry-run output shows each task's cache status before execution. Use this to verify that changing a file correctly invalidates the right caches.
Handling Non-Cacheable Tests
Some tests should never be cached — tests that hit external APIs, integration tests that depend on live database state, or end-to-end tests:
{
"tasks": {
"test:unit": {
"inputs": ["src/**/*.ts", "**/*.test.ts"],
"outputs": ["coverage/**"]
},
"test:e2e": {
"cache": false,
"dependsOn": ["^build"]
}
}
}Setting "cache": false forces the task to always run. Reserve this for tests where freshness matters more than speed.
Workspace Configuration Tips
A few patterns that improve Turborepo testing setups:
Share Jest/Vitest config through a base package:
packages/
config/
jest.config.base.js ← shared config
my-app/
jest.config.js ← extends base
my-lib/
jest.config.js ← extends baseInclude config files in inputs to avoid cache misses:
"inputs": [
"src/**",
"jest.config.*",
"../../packages/config/jest.config.base.js"
]If the base Jest config changes but isn't listed as an input, cached tests may use stale configuration.
Use passWithNoTests to avoid failures in empty packages:
// package.json scripts
{
"scripts": {
"test": "jest --passWithNoTests"
}
}Packages without tests will cache a success result, not fail.
Key Takeaways
- Turborepo caches per-package: each package's test is independently cached and replayable
- Remote caching eliminates redundant CI runs — tests that passed on a developer's machine skip CI
- The
inputsarray inturbo.jsonis the cache key — missing a file means stale cache results --filter=[origin/main]on PRs runs only changed packages; use fullturbo testonmain- Set
"cache": falsefor integration and E2E tests that must always run fresh - Run
--dry-runbefore committing pipeline changes to verify cache behavior
Turborepo doesn't require migrating your test runner. It wraps whatever you already use — Jest, Vitest, Playwright — and adds caching and parallelism on top.