Skip to content

Vitest in Practice: Six Months After I Switched From Jest

Vitest in Practice: Six Months After I Switched From Jest

Six months ago I migrated a Jest suite of about 410 test files to Vitest in a single weekend. The carrot was speed. The stick was the third time I’d had to debug a Jest ESM config to make a Vite-built app testable. I kept telling myself the pain was a one-time tax. The third one-time tax in a row finally broke me.

Here’s the honest after-action report. What got faster, what I had to rewrite, where Jest is still better, and what I’d do differently if I were starting again.

Why I’d been on Jest for years

Jest is the default. That’s the real reason. I’d been using it since 2017, every starter kit shipped with it, and “if it ain’t broke” is a very persuasive argument when you have actual features to ship. The mocking API is good. Snapshot testing is good. The Watch UI is good.

The cracks started showing once half my codebase moved to native ESM and the other half stayed CommonJS. Jest’s ESM support got better, but it never stopped feeling like a config negotiation. I’d ship a perfectly working production build, then watch the test runner choke on the same import path because of some interplay between transform, transformIgnorePatterns, and Babel.

When I shipped a Vite-built React 19 project last year and had to maintain two parallel transformer setups, one for Vite and one for Jest, it felt like the universe was tapping me on the shoulder.

What Vitest actually changes, and what stays

Vitest is a test runner built on top of Vite. That sounds incremental and isn’t. The big practical changes:

  1. One transformer for app and tests. Vitest reuses your Vite config. If your app builds, your tests run with the same transforms. No second TypeScript or Babel config to keep in sync.
  2. Native ESM, no Babel detour. TypeScript and JSX go through esbuild, which is fast and predictable.
  3. Watch mode that’s actually fast. On large projects, Vitest’s smart invalidation is the feature you didn’t know you were missing. Change a file, only related tests re-run, in well under a second.
  4. Jest-compatible API. describe, it, expect, vi.fn (instead of jest.fn). Most test bodies don’t need to change. The differences are at the config layer.

What stays: assertion style, snapshot testing, --coverage, --watch, parallelism across files. If you’ve written a Jest suite, ninety percent of your test bodies port without edits.

Before and after, with a real test

Before, on Jest:

// jest.config.ts
import type { Config } from "jest";

const config: Config = {
  preset: "ts-jest/presets/default-esm",
  testEnvironment: "jsdom",
  extensionsToTreatAsEsm: [".ts", ".tsx"],
  moduleNameMapper: {
    "^(\\.{1,2}/.*)\\.js$": "$1",
    "^@/(.*)$": "<rootDir>/src/$1",
  },
  transform: {
    "^.+\\.tsx?$": ["ts-jest", { useESM: true, isolatedModules: true }],
  },
  transformIgnorePatterns: ["node_modules/(?!(lodash-es|nanoid)/)"],
  setupFilesAfterEach: ["<rootDir>/test/setup.ts"],
};

export default config;

Plus a tsconfig.json carefully aligned with ts-jest’s expectations. Plus a babel config to handle one stubborn package. Plus a comment near the top of the file that just said // here be dragons.

After, on Vitest, the whole config:

// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "node:path";

export default defineConfig({
  plugins: [react()],
  resolve: { alias: { "@": path.resolve(__dirname, "src") } },
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./test/setup.ts"],
  },
});

A real test file barely changes:

// src/components/Button.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { Button } from "./Button";

describe("Button", () => {
  it("fires onClick when clicked", () => {
    const onClick = vi.fn();
    render(<Button onClick={onClick}>Save</Button>);
    screen.getByRole("button", { name: "Save" }).click();
    expect(onClick).toHaveBeenCalledOnce();
  });
});

The only diff against my old Jest version was jest.fn() to vi.fn() and the import line. A codemod handled both.

The speed numbers

Same monorepo I used when I stopped babysitting Tailwind v4’s Oxide engine builds: three packages, around 38,000 lines of TypeScript, 410 test files, MacBook Pro M3, three runs averaged.

Scenario Jest 30 Vitest 3
Full suite, cold 86 s 19 s
Full suite, warm 64 s 11 s
Watch, single-file change 2.4 s 0.3 s
Coverage report (V8) 102 s 24 s

The watch-mode number was the one that quietly changed my day. Three hundred milliseconds is below the threshold where I notice. I keep Vitest running in a split pane now, the way I used to keep the TypeScript compiler running.

In CI on GitHub-hosted runners, the test job dropped from one minute fifty seconds to twenty-eight seconds. Coverage went from a separate, slower job that I’d skip on most PRs to part of the main test run. Habit change unlocked.

Migration: what actually broke

I want to be honest about the migration tax. A few things did break.

  1. Timers. jest.useFakeTimers() becomes vi.useFakeTimers(), mostly mechanical. A couple of tests that depended on Jest’s “modern” timers needed vi.useFakeTimers({ toFake: ["setTimeout"] }).
  2. Module mocks. jest.mock("module", factory) becomes vi.mock("module", factory). Hoisting works similarly. The one place I tripped was jest.requireActual. The Vitest equivalent is await vi.importActual.
  3. Snapshot format. Vitest’s snapshot serializer is slightly different. I had about a dozen __snapshots__ files with whitespace diffs. vitest -u regenerated them once and that was it.
  4. Coverage. I switched from Istanbul to Vitest’s V8 coverage provider. Numbers shift slightly. If you have a 95% coverage gate in CI, expect a one or two point swing in either direction.
  5. Custom matchers. expect.extend works, but if you used @testing-library/jest-dom, swap it for @testing-library/jest-dom/vitest, then import it in your setup file.

Total time across all five categories, on a 410-file suite: about three hours of careful work, spread across one weekend. I had a codemod and a clear “if it fails, here’s why” mental model for each category.

Where I still use Jest

I want to be fair. Jest is still the right pick in two specific scenarios:

  1. React Native. Vitest’s React Native story is workable but not native. If you’re a mobile shop, the Jest preset that Metro ships with is still the path of least resistance.
  2. Massive legacy CommonJS monorepos. If you’re maintaining a ten-year-old Node service with no plans to move off CommonJS and no Vite anywhere in your stack, the migration isn’t free, and you’re not getting the watch-mode wins. Stay on Jest, keep shipping features.

Read Jest 30’s release notes for context on where Jest itself is going. The team has been doing real work on ESM and performance, and the gap is narrower than it used to be. The reason I moved isn’t that Jest is bad. It’s that maintaining two transformer toolchains, one for the app and one for the tests, has a cost I’d stopped wanting to pay.

My current setup, end of June 2026

For any new TypeScript or React project:

  • vitest with @vitejs/plugin-react and jsdom. That’s the test runner.
  • @testing-library/react and @testing-library/jest-dom/vitest for assertions.
  • vitest --coverage with the V8 provider, run on every PR.
  • A pnpm test:watch script that I leave open in a pane while I work.

Scripts I keep in package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:ui": "vitest --ui"
  }
}

The vitest --ui command is the one I’d recommend to anyone coming off the Jest CLI. It’s a local web UI that shows your test tree, file-by-file diffs on snapshot failures, and watch-mode results. I use it as a debugger for flaky tests.

If you want to see what a real test-and-build pipeline looks like end to end, that’s what I run on the client work I cover here.

One concrete thing to do this week

Pick one test file in your Jest suite. Install Vitest alongside Jest (they coexist fine for a while). Add a vitest.config.ts. Copy the test file’s imports, change jest.fn to vi.fn, and run npx vitest run path/to/file.test.ts.

If it passes, run it in watch mode and change a line. Notice how fast the feedback loop is. That feeling is the case for migrating. Everything else is logistics.

I waited a year longer than I needed to. Don’t be me.