I ran 240 identical tests under Jest 30.5.2 and Vitest 5.0.3 on an Apple M1 (8 cores, 16 GB) with Node 22.23.3, three transform setups in total. The headline: Vitest beat Jest with ts-jest by 1.5x to 3.7x, but lost to Jest with @swc/jest when the tests were spread over many small files. Moving the code took one perl line. Moving the behavior is where it got interesting.
Quick take: Vitest 5 needs Node 22.12+ and Vite 6.4+. A
jest.tovi.rewrite ported all 240 tests, but five behaviors differ: mock call history is cleared per test,mockResetrestores the original implementation, factories must return every export,donecallbacks no longer wait, and type globals move tovitest/globals. Thedonechange is the dangerous one, because a failing test can pass.
How Was It Measured?
The suite is generated, so you can rebuild it. Sixty tiny modules, 240 tests in total, each using jest.fn, jest.mock with a factory and fake timers. Two layouts: 60 files with 4 tests each, and the same tests packed into 10 files.
// gen.mjs [perFile]: 60 modules, 240 Jest tests. perFile=1 gives 60 files, perFile=6 gives 10.
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
const perFile = Number(process.argv[2] ?? 1)
rmSync('test', { recursive: true, force: true })
mkdirSync('src', { recursive: true })
mkdirSync('test', { recursive: true })
writeFileSync('src/clock.ts', 'export const now = () => Date.now()\n')
for (let i = 0; i < 60; i++) {
writeFileSync(`src/mod${i}.ts`, [
`export const scale = (v: number) => v * ${i + 2}`,
`export const sum = (a: number[]) => a.reduce((x, y) => x + y, 0)`,
`export const later = (cb: (n: number) => void) => { setTimeout(() => cb(${i}), 1000) }`,
'',
].join('\n'))
}
for (let first = 0; first < 60; first += perFile) {
let head = `import { now } from '../src/clock'\n`
let body = ''
for (let i = first; i < first + perFile; i++) {
head += `import { scale as scale${i}, sum as sum${i}, later as later${i} } from '../src/mod${i}'\n`
body += `
describe('mod${i}', () => {
afterEach(() => { jest.useRealTimers() })
it('scales', () => { expect(scale${i}(3)).toBe(${3 * (i + 2)}) })
it('sums', () => { expect(sum${i}([1, 2, 3])).toBe(6) })
it('calls a mock', () => {
const spy = jest.fn((n: number) => n + 1)
expect([1, 2, 3].map(spy)).toEqual([2, 3, 4])
expect(spy).toHaveBeenCalledTimes(3)
expect(now()).toBe(7)
})
it('fires a fake timer', () => {
jest.useFakeTimers()
const cb = jest.fn()
later${i}(cb)
jest.advanceTimersByTime(1000)
expect(cb).toHaveBeenCalledWith(${i})
})
})
`
}
head += `\njest.mock('../src/clock', () => ({ now: jest.fn(() => 7) }))\n`
writeFileSync(`test/group${first}.test.ts`, head + body)
}
A second script spawns each runner and reads performance.now() around the whole process, startup included. "Cold" means caches cleared first (jest --clearCache, plus deleting node_modules/.vite for Vitest). "Warm" is the run after a priming run. I did seven rounds, with the runners interleaved inside each round so background noise hit all of them alike.
That noise was real. Other jobs shared this Mac, and the one-minute load average sat between 10 and 27 on eight cores. Treat the absolute seconds as inflated and the ratios as the finding. The ts-jest runs also type-check and the others don't, so that comparison isn't like for like.
What Do the Numbers Say?
Medians of seven rounds, in seconds, wall clock for the whole process:
| Runner | 60 files, cold | 60 files, warm | 10 files, cold | 10 files, warm |
|---|---|---|---|---|
| Jest 30 + ts-jest | 3.9 | 2.6 | 2.6 | 2.2 |
| Jest 30 + @swc/jest | 0.9 | 0.8 | 0.7 | 0.4 |
| Vitest 5.0.3, defaults | 1.7 | 1.7 | 0.6 | 0.6 |
Vitest 5.0.3, isolate: false | 0.7 | 0.6 | 0.5 | 0.5 |
Vitest 5.0.3, fsModuleCache: true | 2.0 | 1.6 | 0.6 | 0.5 |
Three readings. First, "Vitest is faster than Jest" is really "Vitest is faster than ts-jest". Swap the transform for SWC and Jest wins the 60-file layout by about 2x. Second, file count matters more than test count: Vitest's own summary flagged it: 60 workers spawned at roughly 110 ms of startup each, with a hint that isolate: false would be faster. Third, isolate: false cut that to 0.6, but it lets files share module state, so I'd treat it as something to evaluate rather than a free switch. All 240 tests still passed with it.
Vitest isn't faster than Jest, it's faster than ts-jest, and anyone who benchmarks against a slow transform and calls the result a runner win has measured the wrong thing.
Does this predict your suite? Probably not exactly. Tests that do almost nothing aren't a component suite with jsdom and real imports. The shape of the result should carry over: a handful of large files favors Vitest, a swarm of small ones favors a lean Jest transform.
What Does the Mechanical Migration Look Like?
One line of perl (the BSD sed on macOS doesn't understand \b):
#!/bin/sh
# jest.* becomes vi.*, everything else stays
rm -rf vtest && mkdir vtest
for f in test/*.test.ts; do
perl -pe 's/\bjest\./vi./g' "$f" > "vtest/$(basename "$f")"
done
// vitest.config.mts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
include: ['vtest/**/*.test.ts'],
},
})
All 240 tests passed under 5.0.3. Use .mts for the config in a CommonJS package, otherwise Vite warns about ESM syntax in a CJS file. globals: true keeps describe, it and expect global like Jest does, which Testing Library's auto cleanup also relies on, as the React component testing guide shows.
What Does a Find-and-Replace Miss?
I wrote a small test for each of these and ran it under both runners.
1. Call history. clearMocks defaults to true in Vitest 5, so mocks are cleared before every test. Jest keeps the history unless you configure otherwise.
const fn = jest.fn()
test('first', () => { fn(); expect(fn).toHaveBeenCalledTimes(1) })
test('second', () => { fn(); expect(fn).toHaveBeenCalledTimes(2) })
Jest 30 passes. Vitest 5 fails the second test with expected "vi.fn()" to be called 2 times, but got 1 times. The new result is the honest one. The full list of version-5 defaults sits in the Vitest 5 migration guide.
2. mockReset. In Jest, fn.mockReset() on jest.fn(() => 'original') leaves a function returning undefined. In Vitest it restores the original implementation, so expect(fn()).toBeUndefined() fails with expected 'original' to be undefined.
3. Mock factories. Vitest wants every export spelled out. Return only { named } and a default import throws No "default" export is defined on the "./greeting" mock, with a hint to use importOriginal. The same goes for jest.requireActual:
vi.mock('./greeting', async () => {
const actual = await vi.importActual<typeof import('./greeting')>('./greeting')
return { ...actual, named: 'patched' }
})
4. The done callback. This one bit hardest. Here's a test that should fail:
test('callback style', (done) => {
setTimeout(() => {
expect(1).toBe(2)
done()
}, 10)
})
Jest 30 reports Expected: 2, Received: 1. Vitest 5.0.3, run alone, reported one passing test and exit code 0. The assertion fires after the test has already ended. Add a second slow test to the file and the failure surfaces as an unhandled error attributed to whichever test ran last, which is a confusing way to find out. Grep for (done) and rewrite each hit as a promise:
test('promise style', () =>
new Promise<void>((resolve) => {
setTimeout(() => {
expect(1).toBe(2)
resolve()
}, 10)
}))
That version fails correctly. I'd do this grep before anything else in the migration.
5. Types. jest.Mock becomes import type { Mock } from 'vitest', and "types": ["vitest/globals"] replaces "jest" in tsconfig.json. With the old entry left in, tsc stops with Cannot find name 'vi'. If your compiler options have drifted, the tsconfig generator gives you a clean baseline to diff against.
The Vitest guide lists a few more renames. JEST_WORKER_ID becomes VITEST_POOL_ID, jest.setTimeout becomes vi.setConfig({ testTimeout }), legacy timers are gone, and the -t filter joins names with > instead of a space. Mocks in __mocks__ only load when you call vi.mock() explicitly.
In What Order Should You Do It?
Check the runtime first: Node 22.12 or newer, then Vite 6.4 or newer. If you're deciding between runners and not just between versions, the Node test runner comparison covers the third option. Then run the rewrite, grep for (done), switch the type entry, and run the suite. Whatever fails is a test that was leaning on Jest's old defaults, not a bug in the migration.
Will it be faster afterwards? Time your own suite cold and warm before you start. If you're on ts-jest, you'll probably notice. If you already run SWC, the reason to move is the Vite pipeline and the tooling, not the clock.