Confession: I was the guy who defended Jest at every code review for about three years. Someone would open a PR that added Vitest to a side project and I’d leave a comment like “why add a dependency when Jest already works?” I genuinely believed it. Then last spring I spent a full Saturday afternoon fighting a Jest ESM config on a real monorepo, gave up, ran one migration command, and had the whole suite green on Vitest before dinner. That afternoon changed my mind, and this post is me trying to be honest about why.
I’ll give you the short version up front, because I know some of you just want the answer: if you’re on Vite already, or you keep hitting ESM pain in Jest, move to Vitest and don’t overthink it. If you’re on a big stable Create React App or Next.js codebase that already tests fine, there is no prize for switching. The rest of this is the reasoning, the actual config, and the two places where I still reach for Jest.
Why I stuck with Jest for so long
Jest earned its reputation. For most of the last decade it was the default, it came bundled with Create React App, and every Stack Overflow answer assumed you were running it. When something broke, the fix was usually the first result. That kind of gravity is worth a lot when you’re shipping instead of tinkering.
The other thing is that my tests were mostly fine. I had snapshots, I had a jest.config.js I’d copy-pasted between projects for years, and it worked. Nobody rewrites a working test suite for fun. So I kept copying that config forward, project after project, and I stopped noticing how much of it was there to paper over one specific problem.
That problem was modules.
The ESM wall I kept hitting
Here’s the thing that finally wore me down. More and more of the libraries I depend on ship as pure ES modules now. Jest was built in a CommonJS world, and its ESM support is real but still flagged as experimental in the official Jest docs. In practice that means you end up with a pile of workarounds.
My jest.config.js had slowly grown into this:
// jest.config.js — the accumulated scar tissue
module.exports = {
preset: "ts-jest/presets/default-esm",
extensionsToTreatAsEsm: [".ts", ".tsx"],
transform: {
"^.+\\.tsx?$": ["ts-jest", { useESM: true }],
},
transformIgnorePatterns: [
// half a dozen node_modules that ship ESM and Jest can't parse
"node_modules/(?!(nanoid|uuid|@my-org/ui|lowdb|other-esm-pkg)/)",
],
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
},
};
Every time I added a dependency that shipped ESM, the suite would blow up with SyntaxError: Cannot use import statement outside a module, and I’d add another package to that transformIgnorePatterns regex. I did this at least a dozen times across projects. It’s the kind of thing you stop questioning because it’s always been broken, so it feels normal.
The Saturday that broke me, I added one small ESM-only date library and spent two hours getting the regex right. At some point I opened a new terminal and typed npm create vite just to see how bad the alternative was. It was not bad.
What the Vitest migration actually looked like
Vitest runs your tests through Vite’s own transform pipeline, so ESM is just how it works. There’s no experimental flag and no transformIgnorePatterns graveyard. The Vitest docs are refreshingly blunt about this being the whole point.
The migration guide is short enough that I read the entire thing, which almost never happens with tooling docs. Vitest ships a Jest-compatible API on purpose, so describe, it, expect, and most matchers are identical. Their migration guide walks through the handful of real differences, and honestly the handful is small.
The config that replaced all that scar tissue above:
// vitest.config.ts — the whole thing
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./test/setup.ts"],
},
});
That’s it. No transform block, no ignore patterns, no module name mapper. The ESM libraries that Jest choked on just load.
The test files barely changed. This one ran under both, and I only had to swap the import line:
// user.test.ts
import { describe, it, expect } from "vitest"; // was: from "@jest/globals"
import { formatName } from "../src/user";
describe("formatName", () => {
it("handles a normal name", () => {
expect(formatName("qasim", "abrar")).toBe("Qasim Abrar");
});
it("trims stray whitespace", () => {
expect(formatName(" ada ", "lovelace")).toBe("Ada Lovelace");
});
});
If you set globals: true, you don’t even need that import, and existing Jest-style files that rely on global describe and expect run untouched. For a codebase with a few hundred tests, my actual migration was: install Vitest, delete the Jest config, add a ten-line vitest.config.ts, run npx vitest, fix maybe four mocking calls. The whole thing took an afternoon, and most of that was me being paranoid and re-reading diffs.
Where Jest still wins
I don’t want to sell you a clean story, because it isn’t one. There are two situations where I still pick Jest without hesitating.
The first is a large existing project that already tests cleanly, especially older Next.js or Create React App setups where the Jest integration is documented and boring. Boring is a feature in testing. If your suite is green and your team knows the tooling, migrating is pure risk with no reward, and I’d spend that afternoon on something a user will actually notice.
The second is heavy mocking of the module system itself. Jest’s mocking has had years of edge cases beaten out of it. Vitest’s vi.mock is very close and gets closer every release, but if your codebase leans hard on jest.mock with automatic mocking and deep module hoisting, budget real time to port it. That’s the one area where my “one afternoon” story would have been a lie.
There’s also the ecosystem tail. A few niche Jest plugins and reporters don’t have Vitest equivalents yet. It’s rare, but check your specific setup before you promise your team a smooth move.
How I’d decide today
My rule now is embarrassingly simple. If a project already uses Vite for its build, I use Vitest from the first test, because sharing one config and one transform pipeline removes a whole category of “works in the app, breaks in tests” bugs. If a project is on an older bundler and its Jest suite is healthy, I leave it alone until there’s a real reason to touch it, and ESM pain usually becomes that reason on its own.
The speed difference is real but oversold. On my mid-size suite Vitest’s watch mode felt snappier, mostly because it reuses Vite’s module graph, but if someone tells you it’s ten times faster on every project, be skeptical. Measure your own suite. The bigger win for me was deleting config, not shaving seconds.
Testing philosophy matters more than the runner anyway. I wrote about that from the end-to-end side in my post on ripping Cypress out for Playwright, and the same lesson applies here: the tool that fits your build and your team beats the tool that wins benchmarks. Most of the consulting work I do lands on exactly this kind of call, and you can see the sort of projects I take on over on my work page.
Here’s the concrete thing to do this week. Open your jest.config.js and look at your transformIgnorePatterns. If that list has grown past two or three packages, that’s your suite telling you it’s fighting ESM. Spin up a throwaway branch, run npm i -D vitest, drop in the ten-line config above, and run npx vitest once. You’ll know within twenty minutes whether Vitest fits, and worst case you delete the branch and you’ve lost nothing but a coffee’s worth of time.