{"id":180,"date":"2026-05-02T13:04:23","date_gmt":"2026-05-02T13:04:23","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/vitest-vs-jest-2026-what-i-actually-reach-for\/"},"modified":"2026-05-02T13:04:23","modified_gmt":"2026-05-02T13:04:23","slug":"vitest-vs-jest-2026-what-i-actually-reach-for","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/vitest-vs-jest-2026-what-i-actually-reach-for\/","title":{"rendered":"Vitest vs Jest in 2026: What I Reach For (and Why I Switched)"},"content":{"rendered":"<p>Confession: I tried to migrate a client app from Jest to Vitest in late 2024, gave up after a weekend, and went back to my Jest config. Then in January 2025 I tried again on a fresh Next.js project and finished the whole switch in about an hour. Two years later, every new project I start uses Vitest, and I&rsquo;ve quietly migrated four of the six client apps I still maintain.<\/p>\n<p>So this post is the version of the answer I wish I&rsquo;d had in 2024. Where Vitest actually wins. Where Jest still beats it. The real reason I switched (it isn&rsquo;t speed). And the migration playbook I now run on every project, which takes about a week of part-time work.<\/p>\n<p>If you&rsquo;re impatient: use Vitest for anything new, and migrate Jest projects when you&rsquo;re already touching the test config for another reason. Don&rsquo;t migrate just because Twitter says you should.<\/p>\n<h2 id=\"why-i-finally-jumped\">Why I finally jumped<\/h2>\n<p>The thing that finally pushed me wasn&rsquo;t a benchmark. It was an <code>npm audit<\/code> on a Next.js 14 app. Jest had pulled in a chain of <code>@babel\/*<\/code> packages I didn&rsquo;t directly need, half of them flagged for transitive vulnerabilities. I went to upgrade Jest, hit an ESM-related config error, spent two hours reading GitHub issues, and realised I&rsquo;d had this exact conversation with myself before.<\/p>\n<p>Vitest, meanwhile, just works with the Vite config I already have. No Babel. No <code>transformIgnorePatterns<\/code> hieroglyphics. No <code>moduleNameMapper<\/code> ten lines deep. The test runner runs the same transformer my dev server uses, which means if it loads in dev, it loads in tests.<\/p>\n<p>That&rsquo;s the headline. Speed is the marketing copy. Config simplicity is the actual reason most people switch and stay switched.<\/p>\n<h2 id=\"speed-where-the-gap-actually-shows-up\">Speed: where the gap actually shows up<\/h2>\n<p>The benchmarks people share online are a bit misleading. On a small project (under 50 test files), I can&rsquo;t reliably tell the difference. Both runners boot in a second or two and finish a unit suite before I&rsquo;ve stopped typing.<\/p>\n<p>The gap shows up in two specific situations. First, watch mode on big monorepos. On a roughly 800-test suite I have at work, Jest&rsquo;s watch mode takes around 9 seconds to react after a file save. Vitest takes about 2. Multiply that by every TDD loop and the productivity difference is real.<\/p>\n<p>Second, cold starts in CI. Jest spends a chunk of its boot time on Babel transforming everything. Vitest reuses Vite&rsquo;s pre-bundled deps, so the cold start on a fresh CI runner is consistently 30 to 50% faster in my pipelines.<\/p>\n<p>If your project is small and you only run tests in a flat one-shot CI command, you won&rsquo;t notice. Don&rsquo;t switch just for speed.<\/p>\n<h2 id=\"the-config-60-lines-to-4\">The config: 60 lines to 4<\/h2>\n<p>This is where the difference is hard to argue with. Here&rsquo;s a Jest config from a Next.js 14 project I migrated last year, after years of accreted plumbing:<\/p>\n<pre><code class=\"language-js\">\/\/ jest.config.js\nconst nextJest = require('next\/jest')\n\nconst createJestConfig = nextJest({ dir: '.\/' })\n\nconst customJestConfig = {\n  setupFilesAfterEach: ['&lt;rootDir&gt;\/jest.setup.ts'],\n  testEnvironment: 'jest-environment-jsdom',\n  moduleNameMapper: {\n    '^@\/(.*)$': '&lt;rootDir&gt;\/$1',\n    '^.+\\\\.(css|less|scss)$': 'identity-obj-proxy',\n    '^.+\\\\.(svg)$': '&lt;rootDir&gt;\/__mocks__\/svg.js',\n  },\n  transformIgnorePatterns: [\n    '\/node_modules\/(?!(jose|@panva|openid-client|oauth4webapi)\/)',\n  ],\n  testPathIgnorePatterns: ['\/node_modules\/', '\/.next\/', '\/e2e\/'],\n  collectCoverageFrom: [\n    'app\/**\/*.{ts,tsx}',\n    'components\/**\/*.{ts,tsx}',\n    '!**\/*.d.ts',\n  ],\n}\n\nmodule.exports = createJestConfig(customJestConfig)\n<\/code><\/pre>\n<p>The Vitest equivalent on the same project looks like this:<\/p>\n<pre><code class=\"language-ts\">\/\/ vitest.config.ts\nimport { defineConfig } from 'vitest\/config'\nimport react from '@vitejs\/plugin-react'\nimport tsconfigPaths from 'vite-tsconfig-paths'\n\nexport default defineConfig({\n  plugins: [react(), tsconfigPaths()],\n  test: {\n    environment: 'jsdom',\n    setupFiles: ['.\/vitest.setup.ts'],\n  },\n})\n<\/code><\/pre>\n<p>Path aliases come from <code>tsconfig.json<\/code> automatically through <code>vite-tsconfig-paths<\/code>. CSS imports are handled by Vite. The Jose\/OIDC dance from <code>transformIgnorePatterns<\/code> doesn&rsquo;t exist because Vitest treats those packages as ESM correctly the first time.<\/p>\n<p>That config has been stable for over a year now. The Jest one I had to revisit roughly every quarter.<\/p>\n<h2 id=\"esm-was-the-real-reason\">ESM was the real reason<\/h2>\n<p>Here&rsquo;s the thing nobody puts in the marketing: Jest&rsquo;s ESM support has been &ldquo;experimental&rdquo; since 2021, and as of mid-2026 it still is. You can <a href=\"https:\/\/github.com\/jestjs\/jest\/issues\/9430\" rel=\"nofollow noopener\" target=\"_blank\">read the open ESM tracking issue on the Jest repo<\/a> for yourself. It&rsquo;s the kind of issue that makes you close the tab.<\/p>\n<p>The practical pain shows up when a dependency you didn&rsquo;t choose ships ESM-only. Jose did it. Several @octokit packages did it. node-fetch v3 did it. Each time, you end up adding more entries to <code>transformIgnorePatterns<\/code> until the config is effectively a list of every modern package on npm.<\/p>\n<p>Vitest sidesteps this by being ESM-native. Modules are loaded the same way the rest of your app loads them in development. When <code>jose<\/code> ships a new ESM-only release, my tests don&rsquo;t break. They just run.<\/p>\n<p>If your project has zero ESM dependencies, this won&rsquo;t matter to you. But it&rsquo;s been about three years since I started a project where that was true.<\/p>\n<h2 id=\"mocking-where-jest-still-feels-nicer\">Mocking: where Jest still feels nicer<\/h2>\n<p>I want to be fair here. Vitest has a few real ergonomic regressions versus Jest, and the biggest is module mocking.<\/p>\n<p>Jest&rsquo;s <code>jest.mock('.\/foo')<\/code> call is hoisted by Babel automatically, which is convenient and slightly magical. You can write the mock anywhere in the file and it works. Vitest&rsquo;s <code>vi.mock()<\/code> is also hoisted, but the rules are stricter. You can&rsquo;t reference variables defined in the test file inside the factory function unless you wrap them in <code>vi.hoisted()<\/code>:<\/p>\n<pre><code class=\"language-ts\">\/\/ This won't work in Vitest:\nconst fakeUser = { id: 1, name: 'Alice' }\nvi.mock('.\/auth', () =&gt; ({\n  getUser: () =&gt; fakeUser, \/\/ ReferenceError at hoist time\n}))\n\n\/\/ You have to do this:\nconst { fakeUser } = vi.hoisted(() =&gt; ({\n  fakeUser: { id: 1, name: 'Alice' },\n}))\nvi.mock('.\/auth', () =&gt; ({\n  getUser: () =&gt; fakeUser,\n}))\n<\/code><\/pre>\n<p>The first time I hit this I lost 20 minutes to a confusing error. Now it&rsquo;s muscle memory. But Jest&rsquo;s version was, all told, friendlier to read and easier to teach to a junior engineer.<\/p>\n<p>Snapshot testing also still feels slightly more polished in Jest. Vitest has parity for the basics, but the inline snapshot UX in <code>--update<\/code> mode trips me up about once a month.<\/p>\n<h2 id=\"when-i-still-keep-jest\">When I still keep Jest<\/h2>\n<p>Three cases where I haven&rsquo;t migrated, and probably won&rsquo;t.<\/p>\n<p>A legacy Express app I maintain has roughly 1,200 tests, half of which use deeply nested <code>jest.mock<\/code> calls and require contexts. Migrating it is a week of work for a project that gets one PR a month. The math doesn&rsquo;t work.<\/p>\n<p>A React Native app where Jest is the default and the entire RN testing ecosystem assumes it. Vitest can technically run there now, but most RN-specific testing libraries still ship with Jest in their docs. I&rsquo;m not interested in being the team that figures it out first.<\/p>\n<p>And one client project where the senior engineer has strong opinions and zero appetite for tooling changes. Pick your battles.<\/p>\n<h2 id=\"my-one-week-migration-playbook\">My one-week migration playbook<\/h2>\n<p>If you decide to migrate, here&rsquo;s the rough plan I follow now. Spread across a week, in roughly half-hour chunks, it&rsquo;s almost painless. (When I tried to do it in a weekend in 2024, I burned out by Saturday afternoon.)<\/p>\n<p><strong>Day 1.<\/strong> Install <code>vitest<\/code>, <code>@vitest\/coverage-v8<\/code>, and any environment package you need (<code>jsdom<\/code> or <code>happy-dom<\/code>). Write a minimal <code>vitest.config.ts<\/code>. Don&rsquo;t touch any tests yet. Run <code>vitest run __nothing__<\/code> just to confirm the runner boots.<\/p>\n<p><strong>Day 2.<\/strong> Run a single test file under Vitest. Fix imports as needed. About 90% of <code>import { describe, it, expect } from '@jest\/globals'<\/code> lines need to become <code>from 'vitest'<\/code> (or you can set <code>globals: true<\/code> in the config and remove the imports entirely, which is what I now do).<\/p>\n<p><strong>Day 3.<\/strong> Convert your <code>jest.mock<\/code> calls to <code>vi.mock<\/code> and add <code>vi.hoisted()<\/code> where you reference outer variables. This is the most error-prone step. Do it in batches and run the suite between each batch.<\/p>\n<p><strong>Day 4.<\/strong> Replace <code>jest.fn()<\/code> with <code>vi.fn()<\/code>, <code>jest.spyOn()<\/code> with <code>vi.spyOn()<\/code>, etc. The find-and-replace is mostly mechanical. ESLint rules from <code>eslint-plugin-vitest<\/code> catch the rest.<\/p>\n<p><strong>Day 5.<\/strong> Update CI. Replace <code>jest --ci<\/code> with <code>vitest run --reporter=verbose<\/code>. If you collect coverage, swap the coverage command. Delete <code>jest.config.js<\/code>, <code>jest.setup.ts<\/code>, and the entire <code>babel.config.js<\/code> if you only kept it for Jest.<\/p>\n<p><strong>Day 6.<\/strong> Run the suite five times in CI. Watch for flakes. The handful I&rsquo;ve seen in real migrations have always been pre-existing race conditions that Jest happened to mask with its slower scheduling.<\/p>\n<p><strong>Day 7.<\/strong> Delete <code>jest<\/code>, <code>babel-jest<\/code>, and the rest of the Jest dependency tree from <code>package.json<\/code>. Run <code>npm dedupe<\/code>. Smile a little at the size of the diff.<\/p>\n<p>For a Next.js or Vite app, this whole sequence usually takes me under two hours of focused work. For an old CRA app or a complex monorepo, more like a full day.<\/p>\n<p>One concrete action for this week: take your slowest test file in Jest, copy it into a fresh Vitest setup, and just see how it feels. You&rsquo;ll know in ten minutes whether the rest of the migration is worth your time. The <a href=\"https:\/\/vitest.dev\/guide\/migration.html\" rel=\"nofollow noopener\" target=\"_blank\">official Vitest migration guide<\/a> covers the full API mapping if you want a checklist. And if you want the broader runtime context for why testing infra has shifted so much in the last two years, my <a href=\"https:\/\/abrarqasim.com\/blog\/bun-vs-node-2026-what-i-actually-run-in-production\" rel=\"noopener\">Bun vs Node piece from earlier this month<\/a> covers what&rsquo;s running tests in production for me right now.<\/p>\n<p>If you&rsquo;re picking a stack from scratch in 2026, use Vitest. If you&rsquo;ve already got a healthy Jest setup that nobody complains about, leave it. The best migration is the one you do because the friction is real, not because the tooling discourse is loud. Most of what I post about this kind of judgement-call work lives on my <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">portfolio site<\/a>.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;ve been migrating projects from Jest to Vitest since late 2024. Here&#8217;s where Vitest actually wins, where Jest still beats it, and the playbook I use.<\/p>\n","protected":false},"author":2,"featured_media":179,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I've been migrating projects from Jest to Vitest since late 2024. Here's where Vitest actually wins, where Jest still beats it, and the playbook I use.","rank_math_focus_keyword":"vitest vs jest","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[165,197],"tags":[44,199,30,201,63,200,198],"class_list":["post-180","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-javascript","category-testing","tag-javascript","tag-jest","tag-testing","tag-tooling","tag-typescript","tag-vite","tag-vitest"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/180","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=180"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/180\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/179"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=180"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=180"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=180"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}