DEV Community

Cover image for Migrate Jest to Vitest With 3 Parallel Agents, One Directory Each
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

Migrate Jest to Vitest With 3 Parallel Agents, One Directory Each


Nobody on your team wants the Jest-to-Vitest ticket. It is days of jest.fn to vi.fn, a pile of mock factories that need rewriting, and a dozen done callbacks. It is also the best first job you can give parallel agents, because a test file in src/billing almost never depends on one in src/search. The folders split the work for you.

The exceptions are the shared files: the config, the setup file, package.json, and the test helpers every folder imports. Put those on main yourself before any agent starts, and the parallel part stays parallel.

This walkthrough goes from Jest to Vitest 5 (the docs list 5.0.4 as current in their version selector) with three CLI agents such as Claude Code and Codex. Steps 1, 2 and 5 are plain git and npm. Steps 3 and 4 show the git commands, then the same steps in Hermes IDE. I build Hermes, so weigh those parts with that in mind.

Step 1: land the shared layer on main yourself

Vitest 5 needs Node.js 22.12 or later and Vite 6.4 or later, and vite is now a peer dependency (migration guide). npm 7 and later install peers for you. Yarn does not, so Yarn users add vite as well.

npm install -D vitest
Enter fullscreen mode Exit fullscreen mode

Then the config. Two settings in it exist only to make Vitest behave like the Jest suite you are leaving. That way a red test during the migration points at a real difference between the two runners.

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    setupFiles: ['./vitest.setup.ts'],
    // Jest's behaviour, kept until every folder lands
    clearMocks: false,
    sequence: { hooks: 'list' },
  },
});
Enter fullscreen mode Exit fullscreen mode

globals: true gives you describe, it, expect and vi without imports. Jest turns its globals on by default and Vitest does not (Jest migration page). Set environment to whatever your Jest config used.

clearMocks: false is the one that bites quietly. Vitest 5 changed its default to true, so it calls vi.clearAllMocks() before every test, and calls recorded in a setup file, at module top level or in a beforeAll are gone by the time the test reads them (migration guide). Jest's default is false (Jest config). If your jest.config already sets clearMocks: true, drop the line.

sequence.hooks: 'list' runs hooks in Jest's order. Vitest runs them as a stack by default (Jest migration page).

For the TypeScript side of the globals, the globals docs use the types entry in tsconfig.json. Add vitest/globals to the array you already have, and keep jest in it until Step 5, because the folders nobody has migrated yet still need those types:

{
  "compilerOptions": {
    "types": ["jest", "vitest/globals"]
  }
}
Enter fullscreen mode Exit fullscreen mode

If TypeScript complains about the two sets of globals while both are listed, give the migrated folders their own tsconfig until the cleanup commit.

Next, copy whatever Jest's setupFilesAfterEnv loads into vitest.setup.ts and rewrite the copy for Vitest. The original stays until the cleanup commit. Then find every Jest reference that lives outside the three folders you are about to hand out:

git grep -n "jest\." -- ':!src/billing' \
  ':!src/accounts' ':!src/search'
Enter fullscreen mode Exit fullscreen mode

Everything that prints is shared: test/helpers, a root __mocks__ folder, a custom matcher. Migrate it by hand in this commit. If you skip it, all three agents will rewrite the same helper three different ways. Note that Vitest does not load __mocks__ on its own. A module there is used only when a test calls vi.mock() for it (Jest migration page).

Leave jest, jest.config.js and the "test": "jest" script alone for now. The folders nobody has migrated yet still run on them.

git add -A
git commit -m "test: add vitest config and shared setup"
Enter fullscreen mode Exit fullscreen mode

Step 2: write one brief, then copy it three times

Each agent gets the same brief with a different folder name. The scope rule and the done-check matter more than the conversion rules:

Migrate the tests under src/billing/ from Jest to Vitest.
Edit only files inside src/billing/.
Do not touch package.json, package-lock.json,
vitest.config.ts, vitest.setup.ts or test/helpers.
If you need a change there, stop and tell me what and why.

jest.fn -> vi.fn, jest.spyOn -> vi.spyOn
jest.mock -> vi.mock, top level only; values the
  factory uses go in vi.hoisted
factories return named exports ({ default: ... })
jest.requireActual -> importOriginal inside a vi.mock
  factory, await vi.importActual(...) elsewhere
done callbacks -> async test or a Promise
jest.setTimeout -> vi.setConfig({ testTimeout })
jest.Mock -> import type { Mock } from 'vitest'
Never skip, delete or loosen a test to make it pass.

Done when `npx vitest run src/billing/` passes
and `grep -rn "jest\." src/billing/` prints nothing.
Enter fullscreen mode Exit fullscreen mode

Keep the trailing slash in the filter. Vitest's path filter only checks whether the file path contains the string, with no globs (CLI docs), so src/search would also pick up a src/search-index folder.

Most of those rules are a rename. Three of them change code, and they are the ones to check in review.

Hoisting. Jest hoists jest.mock above the imports and lets the factory use outer variables whose names start with mock (Jest docs). This is Jest's own example:

import SoundPlayer from './sound-player';

const mockPlaySoundFile = jest.fn();

jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return { playSoundFile: mockPlaySoundFile };
  });
});
Enter fullscreen mode Exit fullscreen mode

Vitest hoists vi.mock too, but its factory cannot use variables defined outside it at all. Anything the factory needs goes in vi.hoisted (vi.mock docs):

import SoundPlayer from './sound-player';

const { mockPlaySoundFile } = vi.hoisted(() => ({
  mockPlaySoundFile: vi.fn(),
}));

vi.mock('./sound-player', () => ({
  default: vi.fn(function () {
    return { playSoundFile: mockPlaySoundFile };
  }),
}));
Enter fullscreen mode Exit fullscreen mode

Two details in that rewrite are easy to miss. The factory returns { default: ... }, because Vitest needs every export named. And the mock is a function, not an arrow. Since Vitest 4, a mock called with new constructs an instance, and an arrow implementation fails with <anonymous> is not a constructor (Vitest 4 migration). Vitest 5 also throws if vi.mock sits inside a describe, a test or a function, where Vitest 4 only warned. Move those calls to the top of the file, or use vi.doMock, which is not hoisted.

Partial mocks. jest.requireActual becomes the importOriginal argument. Passing import(...) as the path lets TypeScript infer the module type:

vi.mock(import('./config'), async (importOriginal) => ({
  ...(await importOriginal()),
  retries: 0,
}));
Enter fullscreen mode Exit fullscreen mode

Done callbacks. Vitest does not support them, so the test returns a Promise instead:

it('emits ready', () =>
  new Promise<void>((resolve) => {
    server.on('ready', () => resolve());
    server.start();
  }));
Enter fullscreen mode Exit fullscreen mode

Three file folders standing side by side on a shared base plate, each with its own branch line growing out of it
Shared config on main, one folder per agent.

Step 3: start three agents, each in its own worktree

With plain git, each agent gets a worktree: a separate folder on its own branch, sharing one repository.

git worktree add -b vitest/billing ../app-billing
git worktree add -b vitest/accounts ../app-accounts
git worktree add -b vitest/search ../app-search
Enter fullscreen mode Exit fullscreen mode

A new worktree holds only tracked files, so there is no node_modules. Run npm ci in each folder, start an agent there, and paste the brief with that folder's name.

In Hermes, press ⌘N (Ctrl+Shift+N on Windows and Linux), paste the brief and pick the agent. Each task starts on its own hermes/ branch in its own worktree, with the brief as the agent's first prompt, and new worktrees get their dependencies in seconds (README). Per-repository worktree setup is described in the worktree recipes doc. Repeat for the other two folders. Hermes lets you pick the agent per task, so two folders can go to Claude Code and one to Codex.

Your own checks can decide when a Hermes task is done (README). Give each task its folder's command, npx vitest run src/billing/ and so on. A bare npx vitest run would also pick up the two folders other agents still own, and those still use Jest.

Step 4: review each branch, land them one at a time

A green check can also mean an agent switched a test off or deleted it. Before you merge, look for both:

git diff main...vitest/billing \
  | grep -nE '^\+.*\.(skip|todo|only)\('
git diff --stat main...vitest/billing
git grep -cE '(^|[^.[:alnum:]_])(it|test)\(' \
  main -- src/billing/
git grep -cE '(^|[^.[:alnum:]_])(it|test)\(' \
  vitest/billing -- src/billing/
Enter fullscreen mode Exit fullscreen mode

The three-dot diff shows only what the branch changed since it left main. If --stat lists files outside src/billing/, the agent broke the scope rule, and you want to know why before anything lands. The two git grep runs count test calls per file on each side, and the counts should match. In Hermes, use the task's hermes/ branch name in place of vitest/billing.

In Hermes, ⌘G (Ctrl+Shift+G) opens the Review Desk, which shows the changes by turn or by file. A line comment you leave there goes back to the agent that wrote the line (README). Reading by turn helps with this job: the turn where an agent gave up on a factory and wrapped the test in it.skip stands out on its own.

Land the first branch. Then bring the other two up to date:

cd ../app-accounts
git rebase main
Enter fullscreen mode Exit fullscreen mode

Land the second, rebase the third, land it. In Hermes, the Land sheet commits, opens a pull request or merges locally in one step, into the branch the task started from, and can be undone (README).

The conflict to plan for

If the shared layer is complete, the folders do not collide. The file that still can is package.json. Say src/accounts and src/search each used a Jest-only helper package. Neither agent can make its check pass without a Vitest-compatible replacement, and instead of stopping as the brief asked, each installs one. Both branches now change package.json and the lockfile, and whichever rebases second conflicts.

The --stat check from the review step catches this before anything lands. When a branch lists package.json, stop and decide on the dependency yourself.

If it reaches a rebase anyway, npm's documented fix is to resolve package.json by hand and let npm rebuild the lockfile (npm docs):

git rebase main
# CONFLICT in package.json, package-lock.json
# edit package.json, keep both changes, then:
npm install --package-lock-only
git add package.json package-lock.json
git rebase --continue
Enter fullscreen mode Exit fullscreen mode

Two branches reaching toward the same package box from opposite sides, with a small warning flag where they meet
The shared file is where the parallel branches meet.

Step 5: the cleanup commit

When all three folders are on main, the last commit is yours. Uninstall whichever Jest packages you have, remove the config and switch the script:

npm uninstall jest ts-jest @types/jest
git rm jest.config.js
npm pkg set scripts.test="vitest run"
npx vitest run
Enter fullscreen mode Exit fullscreen mode

Take "jest" out of tsconfig.json's types. Then decide whether to keep clearMocks: false and sequence.hooks: 'list'. Switching either to Vitest's default deserves its own small change, so any test that breaks because of it is easy to find.

If you used plain git, remove each worktree with git worktree remove ../app-billing (and the other two), then delete the vitest/ branches with git branch -d (or -D if you squash-merged them).


If this was useful

The pattern works for more than test runners: one shared commit you write by hand, then one folder, one branch and one check per agent. A lint-rule rollout splits the same way. Hermes IDE is free to download, source-available under BSL 1.1, and runs on macOS, Windows and Linux; it is not affiliated with Nous Research, Anthropic, OpenAI or any agent vendor. Download it here, and if it saves you a merge, a star on GitHub helps other developers find it.

The parts of this migration that needed a human eye were TypeScript, not Vitest: module shapes in mock factories, async tests in place of done callbacks, and getting types right so a green check means something. If you want those foundations solid before you hand a codebase to three agents, TypeScript Essentials covers types, narrowing, modules, async and the daily-driver tooling around them. It is book 1 of The TypeScript Library:

Books 1 and 2 are the core path. If you come from Kotlin, Java or PHP, book 3 or 4 takes their place, and book 5 is for whoever owns the TypeScript build at work.

All five books ship in ebook, paperback, and hardcover.

The TypeScript Library — the 5-book collection

Top comments (0)