Skips test files whose import graph hasn't changed, and replays their last passing result.
Most test runs re-execute files that nothing touched. This plugin installs a
custom Vitest pool that, before running a file, hashes its entire
transitive import graph with @maastrich/hashup.
If that hash already has a stored passing result, the file is never
executed — the recorded result is replayed straight through the reporter
pipeline, in-process, without spawning a worker. Anything else is handed to a
real built-in pool (forks by default) and its result is stored for next time.
- Import-graph aware — the cache key is the hash of the file and everything it imports, transitively. Change a helper five modules deep and every test that reaches it re-runs.
- Zero-spawn hits — cached files cost no process, no thread, no transform.
- Passing results only — failures are never cached, so a red test always re-runs.
- Watch-mode safe — invalidated modules drop the memoized hashes.
- Extras — fold config files, setup files or lockfiles into every hash.
- Drop-in — one plugin entry; no changes to your test files.
npm install -D @maastrich/vitest-plugin-cache
# or
pnpm add -D @maastrich/vitest-plugin-cache
# or
yarn add -D @maastrich/vitest-plugin-cacheRequires Node.js >= 18. ESM-only.
Peer dependencies are deliberately narrow — the plugin builds on Vitest's pool API and one internal reporter hook:
| Peer | Range |
|---|---|
vitest |
~4.1.0 |
vite |
^7.0.0 |
// vitest.config.ts
import { cache } from "@maastrich/vitest-plugin-cache";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [cache()],
test: {
globals: true,
environment: "node",
},
});That's it. Run your tests twice — the second run should be near-instant. With
--reporter=verbose, replayed tests are prefixed with ⛁ and reported at
0ms; the default reporter collapses passing test names, so there the only
visible sign is the duration.
cache() works by setting test.pool for you. If you'd rather wire it
yourself — or you already build test config programmatically — import the
pool directly:
// vitest.config.ts
import { cachePool } from "@maastrich/vitest-plugin-cache/pool";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
pool: cachePool({ pool: "threads" }),
},
});Don't do both. The plugin replaces
test.pool. Pick one, and select the underlying runner with thepooloption rather than Vitest'stest.pool.
Files that aren't imported by your tests — the Vitest config itself, a setup
file, a lockfile — are invisible to the import graph. List them in extras so
any change to them busts the whole cache:
cache({
extras: ["vitest.config.ts", "vitest.setup.ts", "pnpm-lock.yaml"],
});The plugin must be registered on each project, not at the root. Vitest does
not pass root-level plugins (or root test.pool) down to projects, so a
root-only registration silently caches nothing.
For inline projects:
export default defineConfig({
test: {
projects: [
{
plugins: [cache()],
test: { name: "unit", include: ["**/unit/**/*.test.ts"] },
},
{
plugins: [cache()],
test: { name: "integration", include: ["**/integration/**/*.test.ts"] },
},
],
},
});For projects defined as separate config files (projects: ["packages/*"]), add
cache() to each package's own vitest.config.ts.
One cache store and one hash cache are shared per Vitest instance, so a module
hashed for unit is not re-hashed for integration. Entries are keyed by
<file, hash> only — not by project. If two projects run the same file with
the same import graph, the second replays the first's result under its own
project name. Give projects separate cacheDir values if that isn't what you
want.
.vitest-cacheBoth cache() and cachePool() take the same options object.
| Option | Type | Default | Description |
|---|---|---|---|
cacheDir |
string |
.vitest-cache |
Where results are stored, resolved against Vitest's root. |
pool |
"threads" | "forks" |
"forks" |
Built-in pool used to actually run cache misses. |
extras |
string[] |
— | Extra files folded into every file's hash. Any change to them invalidates everything. |
- Hash — for each test file,
hashupresolves the full transitive import graph (respectingtsconfigpaths, package exports and conditional imports) and produces a deterministic SHA-256 hash of it, plus anyextras. - Look up — the hash is looked up in the store: an in-memory map first,
then
<cacheDir>/<path/to/test/file>/<hash>.json. Entries older than 7 days are treated as misses. - Hit — the stored
Filetask tree is cloned, re-tagged with the current project name, prefixed with⛁, zeroed to0ms, and pushed through Vitest's reporter pipeline. No worker is spawned and no module is transformed. - Miss — the file is forwarded to a real
forks/threadsworker, spawned lazily on the first miss. If everything hits, no worker ever starts. - Store — once the worker finishes, files whose result state is
passare serialized (via@ungap/structured-clone, so errors and non-JSON values survive) and written under their hash. Failures are not stored.
Watch mode: whenever Vitest reports invalidated modules, the memoized hash graph is dropped and rebuilt — hashing is cheap (file reads, all memoized).
- Only file-level caching. A single changed line in a test file re-runs the whole file.
- Impure tests are not tracked. The hash covers source files only. Tests
that depend on env vars, network state, the clock, or a database will happily
replay a stale pass — put anything file-shaped into
extras, and leave the plugin out where purity isn't guaranteed (plugins: [pure ? cache() : null]). - Replayed durations are reported as
0ms, so aggregate timings on a cached run are not comparable to a cold one. - The replay path uses
Vitest._reportFileTask, an internal API. That's why thevitestpeer range is pinned to~4.1.0.
import { cache, cachePool, CacheStore } from "@maastrich/vitest-plugin-cache";
import type { CachePoolOptions, CacheOptions, TestResult } from "@maastrich/vitest-plugin-cache";cache(options?: CachePoolOptions): Plugin— Vite plugin that installs the cache pool.enforce: "post".cachePool(options?: CachePoolOptions): PoolRunnerInitializer— the pool itself, fortest.pool. Also available from@maastrich/vitest-plugin-cache/pool.CacheStore— the underlying store (hit,restore,save,clearCache,getCacheStats). Exported for tooling; you don't need it for normal use.
To wipe the cache, delete the directory:
rm -rf .vitest-cacheIssues and PRs welcome. The repo uses Vite+ — use vp
for everything:
vp install # install dependencies
vp check # format + lint + type-check
vp test # run tests
vp pack # buildtest-projects/single-project and test-projects/multi-project are end-to-end
fixtures consuming the built plugin — handy to verify a change against a real
Vitest run.