Caching Test Results With Turborepo Remote Cache
A remote cache turns “run the tests” into “prove the tests were already run for exactly this input”, which in a busy monorepo removes most of the work from most pipelines. It is also the one optimisation that can make a green build meaningless, because a cache hit on incomplete inputs replays a pass for code that changed. The difference between the two outcomes is entirely in how carefully the task inputs are declared. This guide covers setting up Turborepo’s remote cache for a test task, declaring inputs so hits are correct rather than merely frequent, handling environment variables that change behaviour, and the checks that prove the cache is telling the truth. It assumes Turborepo 2.x over a pnpm workspace and follows monorepo and workspace testing.
Root Cause Analysis
A task cache is a function from inputs to outputs. Turborepo hashes the declared inputs — files, dependencies, environment variables, the task definition itself — and if that hash has been seen before, it replays the stored result instead of running the task. The correctness of the whole scheme rests on one property: everything that can change the result must be in the hash.
That property is violated in three characteristic ways. Files that affect behaviour but are not in inputs — a root setup file, a shared TypeScript configuration, a fixture directory outside the package. Environment variables that change behaviour but are not declared, so the same hash covers a run with TZ=UTC and one without. And implicit dependencies on sibling packages that are not declared in package.json, so a change upstream does not invalidate downstream hashes.
The failure mode of each is identical and unhelpful: a green run that proves nothing, indistinguishable from a green run that proves everything. The reason this matters more for tests than for builds is that a stale build usually breaks something visible quickly, while a stale test result quietly removes your safety net.
Reproducible Setup
Start with a task definition that declares the test task and its outputs, then connect the remote cache.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["tsconfig.base.json", "vitest.workspace.ts", ".nvmrc"],
"globalEnv": ["CI", "TZ"],
"tasks": {
"test": {
"inputs": ["src/**", "test/**", "vitest.config.ts", "package.json"],
"outputs": ["coverage/**", "junit.xml"],
"env": ["VITEST_POOL", "NODE_OPTIONS"]
}
}
}
npx turbo login
npx turbo link # connects the repository to the remote cache
# .github/workflows/test.yml — CI pushes and reads the same cache
- run: pnpm turbo run test
env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
Implementation
Step 1 — Declare inputs narrowly but completely. Narrow inputs mean more cache hits; complete inputs mean those hits are correct. The two pull in opposite directions, and completeness wins every time there is doubt.
"test": {
"inputs": [
"src/**",
"test/**",
"vitest.config.ts",
"package.json",
"!**/*.md",
"!**/__snapshots__/**/*.snap.bak"
]
}
Step 2 — Move cross-package files into globalDependencies. Anything outside a package that affects its tests belongs here, and changing it correctly invalidates every task.
"globalDependencies": [
"tsconfig.base.json",
"vitest.workspace.ts",
"test/fixtures/**",
"patches/**"
]
Step 3 — Declare every environment variable the tests read. An undeclared variable is invisible to the hash, so a run with a different value happily reuses the previous result.
"test": {
"env": ["TZ", "LOCALE", "FEATURE_FLAGS", "DATABASE_URL"],
"passThroughEnv": ["GITHUB_RUN_ID"]
}
The distinction matters: env values are part of the hash, while passThroughEnv values are visible to the task but deliberately excluded — the right place for a run identifier that changes every time and would otherwise defeat caching entirely.
Step 4 — Make outputs complete, or a hit produces no artifacts. When a task is replayed from cache, Turborepo restores only the declared outputs. A coverage report or a JUnit file that is not declared simply will not exist after a cache hit, and the step that uploads it will fail confusingly.
"test": {
"outputs": ["coverage/**", "junit.xml", "reports/**"]
}
There is a judgement call hidden in Step 1 that is worth making explicitly rather than by accident. Excluding Markdown files from a package’s test inputs is nearly always safe and buys real hit-rate, because documentation does not change behaviour. Excluding a fixtures directory is nearly always wrong, even though it is tempting when the fixtures are large and change often, because a fixture is an input to the test in the most literal sense. When unsure, include the file: the cost of an unnecessary miss is a few seconds of compute, while the cost of an unwarranted hit is a false green that nobody will question.
Step 5 — Keep the cache honest with a scheduled full run. Once a night, run with caching disabled. If the results differ from the cached pipeline, an input declaration is incomplete and you have found it before it matters.
# .github/workflows/nightly.yml
- run: pnpm turbo run test --force
Verification
The first check is that hits happen at all. Run twice in a row with no changes and read the summary.
pnpm turbo run test
# Tasks: 12 successful, 12 total
# Cached: 0 cached, 12 total
# Time: 3m12s
pnpm turbo run test
# Tasks: 12 successful, 12 total
# Cached: 12 cached, 12 total
# Time: 1.2s >>> FULL TURBO
The second and more important check is that a hit does not happen when it should not. Touch each class of input and confirm the hash changes — this is the test of your declaration, and it takes two minutes.
echo "// touch" >> packages/ui/src/Button.tsx && pnpm turbo run test --dry=json | jq -r '.tasks[] | select(.cache.status=="MISS") | .taskId'
# @acme/ui#test
# @acme/web#test ← dependents invalidated too
TZ=America/New_York pnpm turbo run test --dry=json | jq -r '.tasks[0].cache.status'
# MISS ← declared env var changed the hash
Finally, confirm the nightly uncached run agrees with the cached pipeline. A difference is the only reliable evidence that something is missing from the inputs, and it is worth investigating immediately rather than attributing to flakiness.
One further habit pays for itself in a large repository: record the cache hit rate over time alongside the pipeline duration. A hit rate that falls steadily usually means an input declaration has become too broad — a generated file landing inside src, a version string written into a package on every build — and the pipeline gets slower for a reason nobody would otherwise connect to caching. Both numbers belong in the same dashboard as the rest of the pipeline metrics described in test observability and reporting.
Troubleshooting
Symptom: no cache hits in CI although local runs hit. Diagnosis: the CI environment differs in something hashed — a different Node version from .nvmrc, an environment variable such as CI that the task declares, or an absolute path leaking into a configuration file. Fix: compare hashes with --dry=json between the two environments and read which input differs; the dry run prints the hash inputs it used.
Symptom: a cached pass for code that definitely changed. Diagnosis: the changed file is not in inputs or globalDependencies. Fix: add it, then re-run the probe from the verification section for that file class. This is the failure worth treating as urgent, since everything downstream of it is now unverified.
Symptom: the coverage upload step fails on cache hits. Diagnosis: coverage/** is not declared as an output, so nothing is restored when the task is replayed. Fix: declare every artifact a later step consumes; the cache restores exactly what it was told to keep, and nothing else.
FAQ
Is a remote cache safe for a test task at all?
Yes, provided the inputs are complete — and the discipline of declaring them is valuable in itself, because it forces you to notice the hidden couplings that make runs non-reproducible. The nightly uncached run is the safety net that catches an incomplete declaration, and it costs one scheduled job.
Should developers share the cache with CI?
Sharing is where most of the benefit comes from: a developer checking out a colleague’s branch pays nothing for packages CI already tested. The risk is a poisoned entry from an unusual local environment, which is why globalEnv should include anything that differs between a laptop and a runner, so the two produce different hashes when they genuinely differ.
How does this interact with running only affected packages?
They are complementary and it is worth knowing which is doing the work. Affected-selection decides which tasks to consider; the cache decides which of those actually execute. Together they usually reduce a monorepo pipeline to the handful of packages a change genuinely touched, as described in running only tests affected by a change.
What about flaky tests and caching?
A cache records the result it saw, so a flaky test that happened to pass will be replayed as a pass until its inputs change. That is an argument for fixing flakiness rather than against caching, but it does mean a cached green is only as trustworthy as the suite’s determinism — one more reason to keep the containment discipline from flaky test mitigation.
Related
- Back to Monorepo & Workspace Testing
- Caching dependencies and test artifacts in CI — the layer beneath task caching.
- Running only tests affected by a change — selection, as distinct from caching.
- Wiring Vitest workspace projects in a pnpm monorepo — the per-package tasks being cached.