Test caching in action
You've seen "cache hit" and "cache miss" in test output, but how does Turborepo decide what to cache? Understanding cache behavior helps you optimize CI/CD pipelines, debug cache issues, and confidently ship faster builds.
Test caching saves massive amounts of time in CI. If only 2 of 20 packages changed, why re-run tests for the other 18? You'll learn what Turborepo hashes, what triggers invalidation, and how to leverage caching for maximum speed.
Outcome
Understand Turborepo's caching mechanism for tests and learn strategies for maximizing cache hits.
Fast track
- Examine what Turborepo hashes for test caching
- Trigger cache misses with different types of changes
- See selective cache invalidation in action
- Understand remote caching implications
Hands-on exercise 5.4
Experiment with test caching to understand how it works.
Requirements:
- Run tests and observe cache behavior
- Change source files and see cache invalidation
- Change test files and see cache invalidation
- Change unrelated files and see cache persist
- Add new test file and see selective invalidation
- Understand what Turborepo includes in cache hash
Implementation hints:
- Turborepo hashes source files, test files, package.json, and dependencies
- Changes outside the package don't invalidate its cache
- Adding a test file triggers cache miss for that package only
- Use
--dry=jsonto see task hash details
What Turborepo hashes
When you run turbo test, Turborepo creates a hash of:
1. Source files in the package
- packages/ui/src/button.tsx
- packages/ui/src/card.tsx
- packages/ui/src/code-block.tsx
2. Test files
- packages/ui/src/button.test.tsx
- packages/ui/src/card.test.tsx
- packages/ui/src/code-block.test.tsx
3. Package configuration
- packages/ui/package.json (dependencies, scripts)
- packages/ui/tsconfig.json
- packages/ui/vitest.config.ts
4. Global configuration
- turbo.json
- Root package.json
- .gitignore (affects file detection)
5. Workspace dependencies
packages/typescript-config(extended bypackages/ui)packages/eslint-config(imported bypackages/ui)
Hash formula:
hash = SHA256(
source_files +
test_files +
package_config +
global_config +
workspace_dependencies +
task_command
)
If the hash matches a previous run, Turborepo replays cached output.
Try it
1. Baseline - full cache hit
Run tests twice:
turbo test
turbo testOutput (second run):
@geniusgarage/ui:test: cache hit, replaying outputs
Tasks: 1 successful, 5 total
Cached: 1 cached, 5 total
Time: 98ms ⚡
Perfect cache hit. Hash unchanged.
2. Change source file
Edit packages/ui/src/button.tsx:
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
const baseStyles = 'px-4 py-2 rounded-md font-semibold transition-colors'
const variants = {
primary: 'bg-blue-500 text-white hover:bg-blue-600',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
// Added comment - hash changes
}Run tests:
turbo testOutput:
@geniusgarage/ui:test: cache miss, executing
@geniusgarage/ui:test: ✓ src/button.test.tsx (5)
...
Tasks: 1 successful, 5 total
Cached: 0 cached, 5 total
Time: 1.187s
Cache miss! Even a comment changed the hash.
3. Change test file
Revert button.tsx, then edit packages/ui/src/button.test.tsx:
describe('Button component', () => {
it('renders with children', () => {
render(<Button>Click me</Button>)
expect(screen.getByText('Click me')).toBeInTheDocument()
// Added comment
})Run tests:
turbo testOutput:
@geniusgarage/ui:test: cache miss, executing
Cache miss again. Test file changes invalidate cache.
4. Change unrelated file
Revert test changes, then edit apps/web/app/page.tsx:
export default function Home() {
// Changed app code, not packages/ui
return <div>...</div>
}Run tests:
turbo testOutput:
@geniusgarage/ui:test: cache hit, replaying outputs
Tasks: 1 successful, 5 total
Cached: 1 cached, 5 total
Time: 102ms ⚡
Cache hit! Changes in apps/web don't affect packages/ui tests.
5. Add new test file
Create a new test for a component that doesn't exist yet:
import { render, screen } from '@testing-library/react'
describe('Input component', () => {
it('placeholder test', () => {
// Just a placeholder to demonstrate cache behavior
expect(true).toBe(true)
})
})Run tests:
turbo testOutput:
@geniusgarage/ui:test: cache miss, executing
@geniusgarage/ui:test: ✓ src/button.test.tsx (5)
@geniusgarage/ui:test: ✓ src/card.test.tsx (4)
@geniusgarage/ui:test: ✓ src/code-block.test.tsx (5)
@geniusgarage/ui:test: ✓ src/input.test.tsx (1)
Cache miss. New test file = new hash.
Delete the placeholder test file:
rm packages/ui/src/input.test.tsx6. Change dependency (packages/typescript-config)
Edit packages/typescript-config/base.json:
{
"compilerOptions": {
"target": "ES2017",
"strict": true, // No actual change, just testing
...
}
}Run tests:
turbo testOutput:
@geniusgarage/ui:test: cache miss, executing
Cache miss! packages/ui depends on packages/typescript-config, so config changes invalidate ui's cache.
Inspect cache hash
Use --dry=json to see what Turborepo hashes:
turbo test --dry=json | jq '.tasks[] | {task: .taskId, hash: .hash}'Output:
{
"task": "@geniusgarage/ui#test",
"hash": "8f7a3b2c1d9e4f5a"
}Each task has a unique hash. Running the same command again produces the same hash (if inputs unchanged).
Cache invalidation scenarios
Scenario 1: Source file change
What changed: packages/ui/src/button.tsx Result: packages/ui tests cache miss Why: Source files are part of hash
Scenario 2: Test file change
What changed: packages/ui/src/button.test.tsx Result: packages/ui tests cache miss Why: Test files are part of hash
Scenario 3: Dependency change
What changed: packages/typescript-config/base.json
Result: packages/ui tests cache miss (packages/ui extends typescript-config)
Why: Workspace dependencies are part of hash
Scenario 4: Unrelated app change
What changed: apps/web/app/page.tsx Result: packages/ui tests cache HIT Why: Apps don't affect package hashes (no dependency)
Scenario 5: Global config change
What changed: turbo.json test task Result: ALL package tests cache miss Why: Global config affects all tasks
Scenario 6: Script change
What changed: packages/ui/package.json test script Result: packages/ui tests cache miss Why: Task command is part of hash
Cache storage
Local cache location:
node_modules/.cache/turbo/
├── 8f7a3b2c1d9e4f5a.tar.zst ← Cached test output
├── 1a2b3c4d5e6f7g8h.tar.zst
└── ...
Each hash gets a compressed archive of:
- Terminal output (stdout/stderr)
- outputs (coverage/** if configured)
Cache size management: Turborepo automatically prunes old cache entries. Default: keep recent hashes.
Remote caching (vercel)
Local caching:
- Cache stored on your machine
- Lost when you clone fresh or switch machines
- Great for dev workflow
Remote caching (Vercel):
- Cache stored in the cloud
- Shared across team and CI
- CI builds can reuse local dev cache!
Enable Vercel remote caching:
pnpm dlx turbo login
pnpm dlx turbo linkNow your cache is shared:
Developer 1: Runs tests → Uploads cache
Developer 2: Runs tests → Downloads cache (instant!)
CI: Runs tests → Downloads cache from dev
Massive CI speedup. If your team already tested locally, CI gets instant cache hits.
Cache hit rate optimization
Strategies for maximizing cache hits:
-
Minimize global config changes
- Changing turbo.json invalidates ALL caches
- Make task config changes in batches
-
Structure packages by change frequency
- Stable packages (ui, utils) get more cache hits
- Frequently-changed packages (apps) get fewer hits
-
Use remote caching in CI
- Don't rebuild what devs already tested
- Share cache across PR builds
-
Keep test scripts stable
- Changing
"test": "vitest run"invalidates cache - Avoid script churn
- Changing
-
Scope dependencies correctly
- Over-depending causes unnecessary cache misses
- packages/ui shouldn't depend on apps
Real-world impact
Without caching:
turbo test # 12 packages
Time: 4min 23s # Every test runs every timeWith local caching:
turbo test # 2 packages changed, 10 cached
Time: 45s # Only changed packages testWith remote caching in CI:
# CI build after dev already ran tests
turbo test # All 12 packages cached remotely
Time: 8s # Just downloads and replays cache4min 23s → 8s is a 33x speedup!
Commit
No code changes in this lesson - it's all about understanding cache behavior.
Done-when
Verify you understand test caching:
- Ran tests twice and saw cache hit
- Changed source file and saw cache miss
- Changed test file and saw cache miss
- Changed unrelated file and saw cache hit
- Added new test file and saw cache miss
- Changed dependency (config) and saw cache miss
- Understood what Turborepo hashes (source, tests, config, deps)
- Understood cache invalidation scenarios
- Learned about local vs remote caching
- Understood cache hit rate optimization strategies
- Saw real-world impact numbers (4min → 8s)
What's Next
Section 4 complete! You have:
- Vitest configured in packages/ui
- 14 passing component tests
- Test task in Turborepo pipeline
- Understanding of intelligent test caching
Section 5: Environment Variables - You'll learn how to manage environment variables in a monorepo, share secrets across apps securely, and configure different environments (dev, staging, production). You'll see how Turborepo handles env vars in caching and how to avoid cache poisoning with sensitive data.
Was this helpful?