Yesterday I decided to bite the bullet: I migrated away from Jest to Vitest which I held of for a number of years. In this post I will explain the reason why, and document the how.

When Jest first came on the scene it was a revolution. It allowed for running unit tests quickly without use of a browser. This made it possible to run hundreds of tests in seconds. Paired with a nice Command Line Interface, a capable watch mode, and excellent documentation it was a delightful library.
Years later Vitest came on the scene it was created by the same people behind Vite and Vue. Vitest took ample inspiration from Jest: most APIs work the same such as describe and it, but it came with a key difference: support for ESM (ECMAScript Modules).
JavaScript is thirty years old and at the time of release it did not have a module system. In a programming language a module system allows you to import / export function, variables, class definitions etc etc. A module system makes code more easily re-usable, and allows you to organize your code.
Module systems are great, so if your programming language du jour does not have one, people will invent them: there is CJS (CommonJS) , UMD (Universal Module Definition) and AMD (Asynchronous Module Definition).
CJS was the module system that came with Node.js so it quickly became very popular, an example:
// CommonJS
// File utils.js
function sum(a, b) {
return a + b
}
module.exports = {
sum
};
// File main.js
const { sum } = require('./utils.js')The TC39 technical committee behind ECMAScript (creators of the JavaScript language specification) decided that JavaScript needed an official module system. This led to the creation of the ESM standard. Take a look:
// ESM
// File utils.js
export function sum(a, b) {
return a + b
}
// File main.js
import { sum } from './utils.js'With the ESM standard released, and working in browsers, it quickly became very popular. Only CJS which is and was widely used in Node.js environments is still around. Node.js however supports ESM these days, and support keeps getting better and better.
When library / framework creators release their packages on NPM it is not uncommon that they provide both ESM and CJS version. However more and more packages are dropping their CJS versions, and more newer packages do not support CJS to begin with.
Now what does this have to do with Jest?
Jest as an older library was built on the foundations of CJS. So when it encounters ESM which most React projects are, it has to transform them to CJS. This transformation process can sometimes fail spectacularly with the following error:
Jest encountered an unexpected token
Jest failed to parse a file. This happens e.g. when your code or its dependencies use non-standard JavaScript syntax, or when Jest is not configured to support such syntax.
Out of the box Jest supports Babel, which will be used to transform your files into valid JS based on your Babel configuration.
By default "node_modules" folder is ignored by transformers.
Here's what you can do:
• If you are trying to use ECMAScript Modules, see https://jestjs.io/docs/ecmascript-modules for how to enable it.
• If you are trying to use TypeScript, see https://jestjs.io/docs/getting-started#using-typescript
• To have some of your "node_modules" files transformed, you can specify a custom "transformIgnorePatterns" in your config.
• If you need a custom transformation, specify a "transform" option in your config.
• If you simply want to mock your non-JS modules (e.g. binary assets) you can stub them out with the "moduleNameMapper" config option.
You'll find more details and examples of these config options in the docs:
https://jestjs.io/docs/configuration
For information about custom transformations, see:
https://jestjs.io/docs/code-transformationEach time I updated a package, or added a new one these errors would keep popping up. This required hours of digging through GitHub and setting custom moduleNameMapper and transform settings in the Jest configuration.
One of the packages I use is called MSW (Mock Service Worker) decided to take a stand, the maintainer of the project refuses to deal with Jest ESM madness anymore. I cannot say I blame him.
Mock Service Worker (MSW) allow you to intercept fetch / HTTP requests and respond with a canned / fixed response. Ideal for unit testing components that call your back-end.
Vitest is ESM native and it simply does not have these kinds of problems.
I always hoped that Jest would someday support ESM more fully, but the functionality seems stuck on experimental. So on the ESM aspect I was tired of waiting for Jest.
Jest does not support TypeScript out of the box. There is the ts-jest which makes it possible, and is a great effort, but the approach always feels a little brittle to me.
Whenever compile / transpile an error occurs in my Jest project. I have to familiarize myself again with my complex Jest configuration. Is ts-jest colliding with jest after an upgrade? Has babel been updated... etc etc.
With Vitest TypeScript just works out of the box, and there is no need to drown in configuration files. I like my libraries / frameworks to work out of the box. I do like to be able to configure everything, but not out of necessity.
So I decided to bite the bullet and migrate all of my 150 tests from Jest to Vitest.
Vitest 4.0 released in October 2025 comes with a stable "browser mode". This allows you to run your unit tests in browsers. Under the hood Vitest uses Playwright to facilitate this.
The benefits of "browser mode" are that you are testing in a real browser instead of a fake enviroment like JSDOM or happy-dom. This does however come at a cost of speed.
Since I already have a very extensive Playwrite e2e test suite, the use of a real browser did not really add anything. So I decided to stay on JSDOM for the time being.
Once things stabilize a bit I will perhaps switch over to the "browser mode". The visual regression feature looks interesting / promising.
The migration itself was relatively painless and I did it in a single morning. These were the steps I took:
First I removed all Jest related dependencies from my package.json and then Then I installed all Vitest related packages and JSDOM:
- "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/dom": "10.4.1",
- "@types/jest": "30.0.0",
+ "@vitejs/plugin-react": "5.1.2",
+ "@vitest/coverage-v8": "4.0.15",
- "jest": "30.2.0",
- "jest-environment-jsdom": "30.2.0",
- "jest-watch-typeahead": "3.0.1",
+ "jsdom": "27.3.0",
+ "vite-tsconfig-paths": "6.0.1",
+ "vitest": "4.0.15"What the vite-tsconfig-paths package does is allow imports starting with an @ for example import "@/test/fixtures".
Next I updated my scripts section in the package.json:
- "unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js --collectCoverage --watchAll=false",
- "unit:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --collectCoverage --watch",
+ "unit": "vitest run --coverage",
+ "unit:watch": "vitest watch --coverage",Then code wise I made the following changes:
jest.spyOn had to be changed to vi.spyOn,
jest.fn had to be changed to vi.fn,
jest.beforeEach had to be changed to vi.beforeEach,
jest.afterEach had to be changed to vi.afterEach,
jest.useFakeTimers had to be changed to vi.useFakeTimers,
As you can see the Vitest uses the exact same API as Jest. Making the migration almost effortless.
Jest will by default make the it and describe globally available. This has always feelt a bit magical to me, so with Vitest I opted to import them manually like so: import { describe, it } from 'vitest';.
Finally I could remove my hefty jest.config.cjs which had 90 lines and I added this much leaner vitest.config.ts:
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths(), react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./test/setupTests.ts'],
coverage: {
include: [
'src/**/*.{js,jsx,ts,tsx}',
],
exclude: [
'**/mocks.{js,jsx,ts,tsx}',
'**/fixtures.{js,jsx,ts,tsx}',
'**/types.{js,jsx,ts,tsx}',
'**/*-types.{js,jsx,ts,tsx}',
'**/*.stories.{js,jsx,ts,tsx}',
'**/*.e2e.{js,jsx,ts,tsx}'
]
}
}
});I could then run my test and the vast majority of the test passed on the first go!
The test that still failed were the ones that used jest.useFakeTimers in combination with @testing-library/user-event.
With useFakeTimers you can manually influence time. This allows you to test behavior that is time based, without waiting for the actual time to expire. For example: a flash message should disappear after 2 seconds, but I do not want my test to wait an actual 2 seconds. In code:
// Advance time by 2 seconds.
vi.advanceTimersByTime(2000);The @testing-library/user-event mimics actual user behavior in unit tests. For example instead of simply clicking a button it will first hover over the button and then click it, with realistic delays. This makes for a more realistic test, as it mimics an actual user better.
Unfortunately @testing-library/user-event seems somewhat married to Jest, as it creates delays using jest.useFakeTimers by default. This meant having to provide a custom advanceTimers function:
- await userEvent.hover(flash);
+ await userEvent.hover(flash, { advanceTimers: vi.advanceTimersByTime });This still did not work because @testing-library/user-event still hardcodes jest somewhere,so I had to add a hack in my setupTests.ts file:
/*
For some reason @testing-library/user-event is based on Jest to
make it work with vitest we have to override the
`advanceTimersByTime` function.
Hopefully this PR fixed this in the future:
https://github.com/testing-library/user-event/pull/1304
*/
// @ts-expect-error Allow me override the globalThis.
globalThis.jest = {
advanceTimersByTime: vi.advanceTimersByTime.bind(vi)
};Hopefully @testing-library/user-event will become unit test library agnostic in the future. There is already a PR to try and fix this .
Here are the performance numbers running on my device, first Jest:
Test Suites: 60 passed, 60 total
Tests: 1 skipped, 149 passed, 150 total
Snapshots: 0 total
Time: 3.745 s, estimated 4 sAfter the migration using Vitest:
Test Files 60 passed (60)
Tests 150 passed (150)
Start at 11:03:00
Duration 4.80s (transform 1.60s, setup 6.47s, import 9.95s, tests 13.95s, environment 17.13s)As you can see Vitest is actually a second slower! Performance was actually one of the reasons I stayed with Jest for so long.
In the end good performance could not save Jest, I'd rather wait the extra second before I want to tangle with ESM in combination with Jest again.
The keen observer will notice that with Jest it skipped one test. The test was a create form that submitted a POST request that MSW was to intercept and return a fake response for. For some reason the POST request was not intercepted by MSW.
After some digging this turned out to happen because of lack of ESM support. After upgrading to Vitest I simply re-enabled the test and it ran fine.
Jest I salute you! I thank you for you service. I'm grateful that you pushed more engineering in the front-end world of things.
Vitest is a worthy successor in my opinion. I really like that they just kept the API. Successor libraries take note: migration is really easy if you keep the same API!