Skip to content

Is Vitest 5 Actually Faster Than Jest 30?

Jest 30 against Vitest 5.0.3 on the same 240 tests, timed cold and warm, plus the five migration breaks a find-and-replace will not catch.

· · 8 min read
A silver pocket watch lying on a wooden surface

Quick Take

Every Jest to Vitest guide promises speed. I timed the same 240 tests under both and got a more interesting answer: it depends on your Jest transform, and one trap passes tests Jest would fail.

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. to vi. rewrite ported all 240 tests, but five behaviors differ: mock call history is cleared per test, mockReset restores the original implementation, factories must return every export, done callbacks no longer wait, and type globals move to vitest/globals. The done change 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:

Runner60 files, cold60 files, warm10 files, cold10 files, warm
Jest 30 + ts-jest3.92.62.62.2
Jest 30 + @swc/jest0.90.80.70.4
Vitest 5.0.3, defaults1.71.70.60.6
Vitest 5.0.3, isolate: false0.70.60.50.5
Vitest 5.0.3, fsModuleCache: true2.01.60.60.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.

Share this Post on X Bluesky

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.

Open cardboard boxes stacked on the floor of an empty room
Photo by Luke Heibert on Unsplash

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.

A room with packed moving boxes and houseplants by a window
Photo by Dina Badamshina on Unsplash

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.

Frequently Asked Questions

Is Vitest faster than Jest?
Against Jest with ts-jest, yes: on 240 trivial tests Vitest 5.0.3 finished in about 1.7 seconds warm in 60 files and 0.6 seconds in 10 files, where ts-jest needed 2.6 and 2.2. Against Jest with @swc/jest it was slower in the 60-file layout (1.7 against 0.8 seconds) and roughly level in the 10-file one. The transform you start from decides the answer.
Can I migrate from Jest to Vitest with a codemod?
Most of it, yes. Replacing the jest. namespace with vi. moved all 240 tests in this benchmark and they passed. What a rewrite cannot see is behavior: clearMocks defaults to true in Vitest 5, mockReset restores the original implementation, mock factories must return every export explicitly, and the done callback no longer waits.
Which Node and Vite versions does Vitest 5 need?
Vitest 5 requires Node.js 22.12 or newer and Vite 6.4 or newer, according to the release announcement. The benchmark here ran on Node 22.23.3. If you are still on Node 20, upgrade the runtime before touching a single test file.