- name
- vitest-migration
- description
- Migrate an Nx repo package's unit tests from Jest to Vitest, reusing the shared setup that packages/workspace established. Use when asked to "move <pkg> to vitest", "migrate <pkg> tests off jest", or "run <pkg> unit tests with vitest".
- allowed-tools
- Read, Glob, Grep, Agent, Edit(*), Write(*), Bash(pnpm nx *), Bash(npx nx *), Bash(nx *), Bash(git *), Bash(ls *), Bash(cat *), Bash(head *), Bash(tail *), Bash(sed *), Bash(grep *), Bash(rg *), Bash(find *), Bash(wc *), Bash(echo *), Bash(mkdir *), Bash(rm *), Bash(mv *), Bash(node *), Bash(npx oxfmt *), Bash(gh pr view *), Bash(gh pr diff *)
# 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 of `scripts/unit-test-setup.js`; every
migrated package loads it as its `setupFiles`
- `tools/vitest/nx-source-resolver.mts` — resolves `nx` / `@nx/*` to this
repo's source, for both vite and node
- `tools/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 its `references` — reading all ~114 project tsconfigs on every
run, which lands as a sandbox violation. `tools/vitest` is deliberately a
plain directory, not an Nx project: adding `project.json` makes
`@nx/js:typescript-sync` demand a project reference to a test-only tool from
each consuming package's _published_ `tsconfig.lib.json` (`composite: false`
does not suppress it)
- `packages/workspace/vitest.config.mts` — the config those two plug into
- `packages/workspace/project.json` — `test.inputs` naming 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:
```bash
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:
1. **Spec count and current runtime.** Run `pnpm nx test <name> --skip-nx-cache`
once 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.
2. **Jest config specials** — anything beyond `displayName`/`preset`/
`moduleFileExtensions` is behavior you must reproduce:
- `setupFiles` (e.g. `packages/devkit/jest-setup-nx-workspace-data-dir.js`)
- `moduleNameMapper` (path shims; also `identity-obj-proxy` for CSS)
- `testEnvironment: 'jsdom'` → needs `environment: 'jsdom'` and the
`jsdom` dep
- `modulePathIgnorePatterns` / `testPathIgnorePatterns` → `exclude`
- `resolver` → `resolve.conditions` (see Step 2)
3. **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/*` and
`nx/*` onto `packages/*` source, **and** sets
`NX_WORKSPACE_ROOT_PATH=<repo>/tmp/unit` as a side effect. Both are
reproduced by the shared scripts (Steps 2 and 3).
- `moduleNameMapper` ESM 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/prompts` is load-bearing: the stub answers `undefined`
where 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.mts` already keeps that one. `prettier`'s stub also
pins `resolveConfig: () => 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.
4. **Native bindings** — does the package load `nx/src/native` or a `.node`
file? If yes you need `pool: 'forks'` and the native shim plugin from
`packages/nx/vitest.config.mts`.
5. **Lazy `require()` of TS source** — `grep -rn "require(" packages/<name>/src
--include=*.ts | grep -v "^.*spec"`. Every bare `require()` of a local `.ts`
file needs `@swc-node/register` (Step 2) and can only be mocked through
`mockCjsModule` (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:
```json
{
"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:
```ts
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/nx` and `node_modules/@nx/*` are the _published_ tarballs (dist
only, no source), and their exports maps advertise `@nx/nx-source` entries
pointing at `./src/index.ts` files the tarball does not ship — so the
condition resolves to a file that isn't there. Use `nxSourceResolver()` from
`tools/vitest/nx-source-resolver.mts`, which maps `nx` / `@nx/*` through
the _local_ `packages/<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 lazy `require('@nx/js')` dies with
`Cannot find module '.../node_modules/@nx/js/src/index.ts'`. Keep the flag,
but `tools/vitest/setup.mts` must also patch `Module._resolveFilename`
with 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/internal` too. Use
`{ find: /^@nx\/devkit$/, replacement: ... }`.
- `packages/nx` predates the shared resolver and hard-codes `nx/src/*` and
`nx/bin/*` aliases instead. Don't copy that — the resolver covers it.
- If the package imports `yargs` with CJS-namespace style
(`yargs.terminalWidth()`), alias it to `node_modules/yargs/index.cjs`.
- If the package loads `nx/src/native`, copy the `nx-native-shim` plugin
verbatim — `src/native/index.js` requires TS files and cannot run outside a
transform, so it must be routed to the generated `native-bindings.js` and
externalized.
---
## Step 3 — Wire up the shared setup
Point the config at the shared file; do not write a per-package copy:
```ts
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_agent` deleted, `FORCE_COLOR` deleted and
`NO_COLOR=1` (snapshots are recorded colorless).
- `NX_WORKSPACE_ROOT_PATH` under `tmp/unit/<pid>` — **per worker process**,
unlike jest. The jest resolver set a single `tmp/unit` as 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. Two `packages/nx` specs already carry this same note.
- `@swc-node/register`, with `Error.prepareStackTrace` **restored immediately
after**: the hook installs source-map-support, which mis-maps
vite-transformed frames and breaks error locations _and_ inline-snapshot
updates.
- `Module._resolveFilename` patched with the source mapping (Step 2), plus
`@clack/prompts` → `scripts/jest-mocks/clack-prompts.js`.
- `vi.doMock` graph/workspace-context/native guards, keyed by **absolute
physical path** — mocking the `nx/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._load`
patch. This is the one most easily missed and the most expensive to debug:
generators reach graph builders through lazy `require()`, which `vi.mock`
cannot see, and the unmocked `createProjectGraphAsync` takes
`project-graph.lock` and **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's
`vi.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:
1. **Do not alias a `jest` global in the setup.** A stray `jest.mock` would not
be hoisted by vitest's transform and would silently fail to intercept. Let
it throw.
2. If the package's specs can write repo files, copy
`packages/nx/vitest-write-guard.cjs` and load it through
`execArgv: ['--require', ...]`. It must be `execArgv`, not `setupFiles`:
node snapshots a module's ESM named exports on first import, so a patch
applied from a setup file is invisible to `import { writeFile } from 'fs'`.
(The `packages/nx` migration found a spec that had been overwriting the
repo's real `nx.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:
```json
"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`
```jsonc
{
"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 rewrites `jest.mock(...)`
View on GitHub