Migrate a package's unit tests to Vitest
Move packages/<name>'s unit tests from Jest to Vitest 4, inferred through the
@nx/vitest plugin.
Start from packages/workspace, not packages/nx. The shared machinery a
sibling package needs already exists — read these first and reuse them as-is:
tools/vitest/setup.mts— the port ofscripts/unit-test-setup.js; every migrated package loads it as itssetupFilestools/vitest/nx-source-resolver.mts— resolvesnx/@nx/*to this repo's source, for both vite and nodetools/vitest/tsconfig.json— a leaf tsconfig whose only job is to stop vite's tsconfig lookup. Do not move these files to the workspace root. With no tsconfig beside them, the nearest one is the root solution file, and vite walks itsreferences— reading all ~114 project tsconfigs on every run, which lands as a sandbox violation.tools/vitestis deliberately a plain directory, not an Nx project: addingproject.jsonmakes@nx/js:typescript-syncdemand a project reference to a test-only tool from each consuming package's publishedtsconfig.lib.json(composite: falsedoes not suppress it)packages/workspace/vitest.config.mts— the config those two plug intopackages/workspace/project.json—test.inputsnaming the shared scripts
packages/nx (PR #36754, commit 32dd3fb533) is the original migration but a
poor template: it is the one package that imports almost no siblings, so it
needs neither the source resolver nor the CJS-channel mocks. Consult it only for
vitest-write-guard.cjs and src/internal-testing-utils/cjs-mock.ts.
packages/angular-rspack/vitest.config.mts is the simple end of the spectrum
(no nx source at all).
Argument
The package name (e.g. js, devkit, workspace). The package lives at
packages/<name>/.
Why this is not a find-and-replace
Jest and Vitest disagree on module semantics, not just API names. The
mechanical jest.* → vi.* rename is maybe 80% of the diff and 20% of the
work. The rest is: which channel a mock reaches (ESM graph vs CJS
require()), whether a namespace is frozen, and what resetAllMocks does to a
spy. Budget for hand-fixing specs after the codemod.
Step 0 — Survey the package
Run these and write the answers into tmp/notes/vitest-migration-<name>.md
before touching anything:
ls packages/<name>/jest.config.cts packages/<name>/jest*.js 2>/dev/null
cat packages/<name>/jest.config.cts
cat packages/<name>/tsconfig.spec.json
grep -rl "\.spec\.ts" -c packages/<name>/src | wc -l # rough spec count
pnpm nx show project <name> --json | head -40
Capture:
- Spec count and current runtime. Run
pnpm nx test <name> --skip-nx-cacheonce and record the reported test count and wall time. That number is the parity target in Step 6 — you cannot verify the migration without it. - Jest config specials — anything beyond
displayName/preset/moduleFileExtensionsis behavior you must reproduce:setupFiles(e.g.packages/devkit/jest-setup-nx-workspace-data-dir.js)moduleNameMapper(path shims; alsoidentity-obj-proxyfor CSS)testEnvironment: 'jsdom'→ needsenvironment: 'jsdom'and thejsdomdepmodulePathIgnorePatterns/testPathIgnorePatterns→excluderesolver→resolve.conditions(see Step 2)
- Inherited preset behavior (
jest.preset.js) that Vitest does not get for free:setupFiles: ['../../scripts/unit-test-setup.js']— the workspace-wide project-graph / workspace-context / native guards. This must be ported (Step 3).resolver: '../../scripts/patched-jest-resolver.js'— maps@nx/*andnx/*ontopackages/*source, and setsNX_WORKSPACE_ROOT_PATH=<repo>/tmp/unitas a side effect. Both are reproduced by the shared scripts (Steps 2 and 3).moduleNameMapperESM shims (@clack/prompts,ora,chalk,yargs-parser,prettier,magic-string,oxfmt). Most are pure ESM interop Vitest does not need — but check each for behavior before dropping it.@clack/promptsis load-bearing: the stub answersundefinedwhere the real library drives a synchronous prompt, and a generator that asks a question blocks the worker forever with no test timeout.tools/vitest/setup.mtsalready keeps that one.prettier's stub also pinsresolveConfig: () => null, which matters if the package snapshots formatted output.maxWorkers: 1— Vitest runs files in parallel. Any spec relying on cross-file ordering or a shared mutable temp dir will now fail. This is the main source of "it passed under jest" flakes.
- Native bindings — does the package load
nx/src/nativeor a.nodefile? If yes you needpool: 'forks'and the native shim plugin frompackages/nx/vitest.config.mts. - Lazy
require()of TS source —grep -rn "require(" packages/<name>/src --include=*.ts | grep -v "^.*spec". Every barerequire()of a local.tsfile needs@swc-node/register(Step 2) and can only be mocked throughmockCjsModule(Step 4).
Step 1 — Target inference
@nx/vitest is already registered in nx.json for packages/**/*, so a
vitest.config.mts at the package root is enough to infer <name>:test.
Verify the plugin block still reads:
{
"plugin": "@nx/vitest",
"options": { "testTargetName": "test" },
"include": ["packages/**/*"],
"exclude": ["**/out-tsc/**"]
}
@nx/jest infers test from jest.config.* presence. Both plugins would
claim test, so jest.config.cts must be deleted in the same change, not
left behind "just in case". Also delete any jest-resolver.js and drop
project.json target overrides that reference jest inputs (see the
packages/nx diff — a "test": { "inputs": [..., "patched-jest-resolver.js"] }
block was removed).
Step 2 — Write packages/<name>/vitest.config.mts
Start from packages/nx/vitest.config.mts and keep only what the survey
justified. The load-bearing pieces and why:
export default defineConfig({
root: import.meta.dirname,
cacheDir: '../../node_modules/.vite/<name>/unit',
test: {
watch: false,
globals: true, // specs use bare describe/it/expect/vi
environment: 'node', // or 'jsdom' if the jest config said so
include: ['**/*.spec.ts'],
exclude: ['**/node_modules/**'],
setupFiles: ['./vitest.setup.mts'],
testTimeout: 35000, // matches jest.preset.js
pool: 'forks', // ONLY if native .node bindings are loaded;
// they are not thread-safe across workers
teardownTimeout: 60_000, // specs holding native contexts exit slowly;
// the jest setup hid this behind --forceExit
execArgv: ['--conditions=@nx/nx-source'],
server: { deps: { external: [/\.node$/] } },
},
resolve: {
conditions: ['@nx/nx-source'],
},
plugins: [nxSourceResolver()], // tools/vitest/nx-source-resolver.mts
});
Rules for resolution — the part that most looks solved and isn't:
conditions: ['@nx/nx-source']does NOT replace the jest resolver.node_modules/nxandnode_modules/@nx/*are the published tarballs (dist only, no source), and their exports maps advertise@nx/nx-sourceentries pointing at./src/index.tsfiles the tarball does not ship — so the condition resolves to a file that isn't there. UsenxSourceResolver()fromtools/vitest/nx-source-resolver.mts, which mapsnx/@nx/*through the localpackages/<pkg>/package.json, with a file fallback for deep imports no exports entry covers (@nx/workspace/src/...).execArgv: ['--conditions=@nx/nx-source']on its own actively breaks node resolution, for the same reason: a lazyrequire('@nx/js')dies withCannot find module '.../node_modules/@nx/js/src/index.ts'. Keep the flag, buttools/vitest/setup.mtsmust also patchModule._resolveFilenamewith the same mapping so both channels agree.- Aliases use regex, not strings. Vite string aliases do prefix matching,
so
'@nx/devkit'would rewrite@nx/devkit/internaltoo. Use{ find: /^@nx\/devkit$/, replacement: ... }. packages/nxpredates the shared resolver and hard-codesnx/src/*andnx/bin/*aliases instead. Don't copy that — the resolver covers it.- If the package imports
yargswith CJS-namespace style (yargs.terminalWidth()), alias it tonode_modules/yargs/index.cjs. - If the package loads
nx/src/native, copy thenx-native-shimplugin verbatim —src/native/index.jsrequires TS files and cannot run outside a transform, so it must be routed to the generatednative-bindings.jsand externalized.
Step 3 — Wire up the shared setup
Point the config at the shared file; do not write a per-package copy:
setupFiles: ['../../tools/vitest/setup.mts'],
tools/vitest/setup.mts is the port of scripts/unit-test-setup.js (which
is jest-only — jest.doMock — so it can never be imported from vitest). Read
it before assuming anything is missing; it already does all of the following,
and each line is there because its absence broke packages/workspace:
NX_DAEMON=false,npm_config_user_agentdeleted,FORCE_COLORdeleted andNO_COLOR=1(snapshots are recorded colorless).NX_WORKSPACE_ROOT_PATHundertmp/unit/<pid>— per worker process, unlike jest. The jest resolver set a singletmp/unitas a side effect; with vitest's parallel workers one shared root makes every worker queue on the same lock ("Waiting for graph construction in another process to complete", 35s timeouts).NX_ISOLATE_PLUGINS=false. Otherwise plugin isolation spawns a worker subprocess per plugin that is never torn down, and the spec file stalls to its timeout. Twopackages/nxspecs already carry this same note.@swc-node/register, withError.prepareStackTracerestored immediately after: the hook installs source-map-support, which mis-maps vite-transformed frames and breaks error locations and inline-snapshot updates.Module._resolveFilenamepatched with the source mapping (Step 2), plus@clack/prompts→scripts/jest-mocks/clack-prompts.js.vi.doMockgraph/workspace-context/native guards, keyed by absolute physical path — mocking thenx/src/...specifier routes through the pnpm symlink and keys as a different module, so the mock silently never applies.- The same graph mocks again, on the CJS channel, via a
Module._loadpatch. This is the one most easily missed and the most expensive to debug: generators reach graph builders through lazyrequire(), whichvi.mockcannot see, and the unmockedcreateProjectGraphAsynctakesproject-graph.lockand deadlocks the worker — no output, and no test timeout fires, because the main thread is blocked in a futex. - Pass-through helpers are plain functions, not
vi.fn(), so a suite'svi.resetAllMocks()cannot wipe them into() => undefined.
Add to the shared file (not a package-local one) if the package needs a guard nothing else does, and say so in the PR — every migrated package loads it.
Two more rules:
- Do not alias a
jestglobal in the setup. A strayjest.mockwould not be hoisted by vitest's transform and would silently fail to intercept. Let it throw. - If the package's specs can write repo files, copy
packages/nx/vitest-write-guard.cjsand load it throughexecArgv: ['--require', ...]. It must beexecArgv, notsetupFiles: node snapshots a module's ESM named exports on first import, so a patch applied from a setup file is invisible toimport { writeFile } from 'fs'. (Thepackages/nxmigration found a spec that had been overwriting the repo's realnx.json.)
Finally, name the shared files in the package's project.json so the cache
sees them — they live outside {projectRoot}, so nothing else invalidates on
an edit:
"test": {
"inputs": [
"...",
"{workspaceRoot}/tools/vitest/**/*",
"{workspaceRoot}/scripts/jest-mocks/clack-prompts.js"
]
}
These are not optional bookkeeping. @nx/vitest infers setup.mts and its
tsconfig (nx#36920), but nothing infers the resolver the config imports or the
clack mock the setup loads by path — default is project-scoped — so leaving
them off does not fail loudly; it serves a stale cache hit the next time
someone edits them. Keep the whole tools/vitest/**/* glob rather than naming
the resolver alone, so a helper added there later is covered too.
Step 4 — tsconfig.spec.json
{
"compilerOptions": {
"types": ["vitest/globals", "node"], // was ["jest", "node"]
},
"include": [
// ...
"vitest.config.mts", // replaces "jest.config.ts"
"vitest.setup.mts",
],
}
Drop @types/jest from the package's devDependencies only if no other
project in the repo still needs it there.
Step 5 — Codemod the specs
Apply mechanically, then hand-fix. Prefer one script over 200 manual edits, and commit the codemod pass separately from the hand fixes so review can follow.
Two rules before you run anything:
- Never codemod the whole package blindly. Some files contain
jest.*in strings, not calls — a spec for a codemod that rewritesjest.mock(...)text, generator specs asserting onjest.config.ctscontents, or'@nx/jest:jest'executor names. Build the file list from a grep for real API usage (grep -l 'jest\.[a-z]' | grep -vthe string-only ones) and eyeball it. - Match across newlines.
jest\n .fn()andjest\n .spyOn(...)are common in this repo and a line-baseds/jest\.fn(/vi.fn(/silently misses them, leavingReferenceError: jest is not definedat collection. Useperl -0p(or equivalent) and re-grep for a bare\bjest\bafterwards.
| Jest | Vitest | Note |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| jest.fn / jest.spyOn / jest.mock / jest.doMock / jest.unmock / jest.clearAllMocks / jest.restoreAllMocks / jest.resetModules / jest.mocked | same with vi. | pure rename |
| jest.requireActual<T>(x) | await vi.importActual<T>(x) | factory must become async |
| jest.requireMock(x) | await vi.importMock(x) | factory must become async |
| jest.isolateModules(() => { require(x) }) | vi.resetModules() + await import(x) | for CJS-loaded modules use delete cjsRequire.cache[cjsRequire.resolve(x)]; cjsRequire(x) |
| jest.isolateModulesAsync(async () => …) | vi.resetModules() then fresh await import()s | |
| jest.Mock | import type { Mock } from 'vitest' | type-only import |
| jest.SpyInstance | import type { MockInstance } from 'vitest' | type-only import |
| jest.MockedFunction | import type { MockedFunction } from 'vitest' | type-only import |
| it('x', (done) => …) | return a promise | Vitest has no done callback |
| xdescribe / xit | describe.skip / it.skip | not defined in Vitest |
| import { jest } from '@jest/globals' | delete | vi is global with globals: true |
Hoisting is real in Vitest: vi.mock calls are lifted to the top of the file,
above imports and above any const the factory closes over. Anything a
factory needs must go through vi.hoisted(() => …).
Step 6 — Hand-fix the semantic gaps
This is where the time goes. The catalogue, from the packages/nx migration:
Frozen ESM namespaces. vi.spyOn(semverNamespace, 'gt') throws on a
node builtin or an external ESM package — the namespace object is frozen.
Mock at the module level in spy mode, which keeps the real implementations
until a test overrides one:
vi.mock('semver', { spy: true });
vi.mock('child_process', { spy: true });
Modules the source loads with bare require(). vi.mock never sees that
channel. Use the helper (add it if the package does not have one — it lives in
packages/nx/src/internal-testing-utils/cjs-mock.ts and patches
Module._load):
import { mockCjsModule } from '<path>/internal-testing-utils/cjs-mock';
mockCjsModule(import.meta.url, './run', { runCommand: vi.fn() });
Registrations are per-file (Vitest forks per file), but a swap made for a
single test must be undone with unmockCjsModule / resetCjsMocks or it leaks
into later tests in the same file.
Class mocks must be constructible. vi.fn() returning an object is not
new-able the way jest's auto-mock was. Return a real function with a
prototype, as GuardedWorkspaceContext does in vitest.setup.mts.
vi.resetAllMocks() restores a spy's real implementation rather than
leaving () => undefined like jest. Specs that relied on the jest behavior
(expecting undefined after a reset) need explicit mockReturnValue(undefined).
Setup-file mocks must not use vi.fn() for pass-through helpers. A spec
calling vi.resetAllMocks() would wipe the implementation and surface as
"is not iterable" downstream. Use plain functions, as the workspace-context
mock does.
Hooks that must not return a mock. beforeEach(() => vi.fn()) — Vitest
treats a returned function as a teardown callback. Make the body a block.
Parallelism. With maxWorkers: 1 gone, two spec files sharing a temp dir,
a process.chdir, or a module-level singleton will now collide. Fix by giving
each file its own TempFs root; reach for test.sequential/isolate: false
only after proving the collision is not the spec's own bug.
A spec that hangs with no output and no timeout. The test timeout cannot fire, because the worker's main thread is blocked in a futex — so the usual "which test is slow" reflexes give you nothing. Diagnose it from the outside:
p=$(pgrep -f "workers/forks" | head -1)
cat /proc/$p/wchan # futex_do_wait == blocked, not busy
ps -o pcpu= -p $p # ~0% confirms it is not just slow
ls -l /proc/$p/fd | grep -v socket # the lock file it is stuck on
pgrep -aP $p # a spawned worker/install it waits for
In packages/workspace this was project-graph.lock: real graph construction
running on the CJS channel. The three causes seen so far are all handled by
tools/vitest/setup.mts — CJS graph mocks, NX_ISOLATE_PLUGINS=false, and
the @clack/prompts stub — so first check the setup is actually loaded before
hunting further.
Watch for latent test bugs. Both spec bugs the packages/nx migration
uncovered were assertions that passed while the mock never applied. If a spec
starts failing after the mock finally lands, the test was wrong — fix the
expectation, do not paper over it by restoring the broken mock.
Step 7 — Snapshots
Vitest joins describe and test names with > where jest used a space, so
every existing key reads as new: a plain run appends a full second copy of
the file and leaves the jest entries orphaned. Regenerate from a pristine tree
so -u also drops the old keys:
git checkout -- 'packages/<name>/**/__snapshots__/*.snap'
cp -r <snapshots> tmp/snapshot-baseline/ # keep the jest originals
pnpm nx test <name> --skip-nx-cache -- -u
Then prove the values did not move: parse both sides into {key: value},
normalize the separator (' > ' → ' '), and diff. Key counts and every value
must match — that is the real parity check, not the pass/fail.
toThrowErrorMatchingInlineSnapshot is the known exception: vitest records
[Error: msg] where jest recorded "msg". Same message, different serializer.
Vitest's serializer differs from Jest's elsewhere too. Regenerate, then read the diff:
pnpm nx test <name> --skip-nx-cache -- -u
git diff --stat -- 'packages/<name>/**/__snapshots__/*'
Snapshot churn should be formatting only (quoting, indentation, Object { →
{). Any change in content is a real behavior difference — investigate it
before accepting. Colorless output is guaranteed by the NO_COLOR pin in the
setup file; if you see ANSI codes land in a snapshot, that pin is missing.
Step 8 — Verify
# same test count as Step 0, and it should be dramatically faster
pnpm nx test <name> --skip-nx-cache
# parallel-safety: repeat runs must be stable, not just green once
pnpm nx test <name> --skip-nx-cache
pnpm nx test <name> --skip-nx-cache
# a single file still works (paths relative to the package root)
pnpm nx run <name>:test -- src/utils/some-file.spec.ts
# nothing else broke - EVERY target the project has, not a set you picked
pnpm nx show project <name> --json | jq '.targets | keys'
pnpm nx run-many -t test,build,lint,oxlint -p <name> --skip-nx-cache
pnpm nx sync:check
oxlint is a separate target from lint. Running test,build,lint and
calling it green is how a restricted-import error reaches CI: this repo bans
nx/src/... imports in favour of @nx/devkit/internal*, and only oxlint
catches it. Read the target list rather than assuming the usual three.
Parity is test count, not just a green run. A dropped include pattern or
a silently-skipped directory shows up as a lower count, and a green suite hides
it. If the count differs, find every missing file before proceeding.
Note the caching caveat: after a mechanical sweep, nx affected can replay a
stale cached pass. Always validate with --skip-nx-cache.
Do not run a full nx affected: a new or moved file under a workspace-root
directory marks all ~90 projects affected, which is hours of jest for changes
that touch nothing jest reads. Run one still-on-jest package as a canary
instead — devkit is the most entangled.
Step 8b — Sandbox violations
The migration is not done when CI is green. Nx Cloud reports the task's file reads against its declared inputs, and a vitest suite reads things the jest one did not. Fetch them once the PR has run:
npx nx-cloud get sandbox-reports --branch <PR-number> --since 1d
npx nx-cloud validate sandbox-violations \
.nx/workspace-data/sandbox-reports/<PR-number>/index.json --json
nx reset deletes .nx/workspace-data, and the downloaded reports with it —
re-download after one.
The per-task JSON carries processTree plus a pid on every read, which is
how you attribute a violation instead of guessing. Map them:
tree = {p['pid']: p for p in report['processTree']}
for r in report['unexpectedReads']:
print(r['path'], tree.get(r['pid'], {}).get('cmd'))
The two this migration produced, both worth checking for:
-
Every project's
tsconfig.json, read by the vitest main process. The timestamps show the root solution tsconfig read milliseconds after a workspace-root source file. Cause and fix are in the Step 3 note about where the shared setup lives. Confirm with the resolver vite itself uses rather than a filesystem tracer —tsconfck'sparse()reports what it consulted, and a tracer onfsmisses it because the ESMnode:fs/promisesbindings are snapshotted before a--requirepreload can patch them:const { parse } = await import('<repo>/node_modules/.pnpm/tsconfck@*/node_modules/tsconfck/src/index.js'); console.log((await parse('tools/vitest/setup.mts')).referenced?.length); // 0 == leaf, 114 == solution root -
The repo's
.editorconfig, read by a worker.formatFilesresolves prettier config from disk attree.root, and a spec that pointstree.rootinto the repo (e.g.process.cwd()) picks it up; the jest prettier shim pinnedresolveConfig: () => nulland hid it. Give that spec aTempFsroot instead of declaring the file as an input.
Prefer declaring an input over excluding a path: an over-broad input costs cache misses, an over-broad exclusion buys wrong cache hits. But a violation that only exists because a file sits in the wrong place is a layout bug — fix the layout. Declaring 114 tsconfigs as inputs would have been "correct" and would have quietly wrecked the cache for every vitest suite in the repo.
Step 9 — Clean up and document
- Delete
packages/<name>/jest.config.ctsand any package-local jest resolver or setup file whose behavior you ported. - If this was the last jest project touching a
scripts/jest-mocks/*shim or a branch ofscripts/unit-test-setup.js, delete it. If not, leave it and say so in the PR body — thepackages/nxPR explicitly deferred the deadscripts/unit-test-setup.jsbranches to a follow-up rather than mixing them in. - Update
CONTRIBUTING.md— it documentsnpx jest <path>for targeting a single test. Add the package to the vitest note next topackages/nx. - Format:
npx oxfmt <changed files>(check the branch's ownchecktarget first — a feature branch may still run pretty-quick). - Do not run a full
nx affected: a new file underscripts/marks all 90+ projects affected, which is hours of jest. Nothing jest reads has changed (jest.preset.js,scripts/unit-test-setup.js,scripts/patched-jest-resolver.jsare untouched), so run one still-on-jest package as a canary instead —devkitis the most entangled. - Write
tmp/notes/vitest-migration-<name>.md: before/after test count and wall time, the list of hand-fixed specs and why, and anything deferred.
Commit shape
Follow the reference PR — small, reviewable, conventional-commit slices:
chore(<scope>): add vitest config and setup for <name> unit testschore(<scope>): codemod jest.* to vi.* in <name> specs- one commit per class of hand fix (CJS-channel mocks, frozen namespaces, constructor mocks, hook cleanup, …)
chore(<scope>): regen <name> snapshots for vitestchore(<scope>): remove <name> jest config
PR body: fill the template, state the before/after test count and wall time, list what the vitest config reproduces from the jest setup, and call out any latent test bug the migration exposed.