Skip to content

Moving a React Component Suite from Jest to Vitest

A four-file React suite moved from Jest 30 to Vitest 5.0.5 one fix at a time: alias, CSS modules, jest.mock, snapshots, and a docblock that survived.

· · 7 min read
Bar chart of passing tests out of four after each step of moving a Jest suite to Vitest

Quick Take

Swap the runner and nothing runs. Fix the alias and three of four files still fail. I moved a small React component suite from Jest to Vitest one change at a time and counted what each change bought, and the thing I expected to break didn't.

The suite is four test files I wrote for this article, not somebody's production repo, and it's small on purpose: every file is there to hit one thing a real component suite hits. Jest 30.5.2 runs it green. Then I pointed Vitest 5.0.5 at the same files, on Node 22.23.3 with jsdom 30.1.3 and React 19.3.1, and fixed one thing at a time, running the whole suite after each change.

TL;DR: The jest. to vi. rename is the easy part. For component tests the work is in the config: an alias, CSS module class names, and snapshots that contain asset URLs. The Jest docblock @jest-environment node kept working untouched, which the Vitest docs never say.

Two earlier posts here cover neighbors of this one. The Jest to Vitest 5 benchmark times 240 trivial tests and lists the mock behavior changes. The Vitest component testing guide builds a setup from an empty folder. Neither starts where most people actually are: with a suite that already exists and a jest.config they'd rather not rewrite from memory.

What Is in the Suite?

FileWhat it exercises
counter.test.tsxuser-event click, jest-dom matchers, a CSS module class, the @/ alias
usercard.test.tsxjest.mock with a factory on an aliased module, findByText
snapshot.test.tsxtoMatchSnapshot on a container that includes an imported SVG
server-only.test.ts@jest-environment node docblock, asserts typeof document is undefined

Under Jest it needs a small jest.config.cjs, and every line of it is a translation job later:

// jest.config.cjs
module.exports = {
  testEnvironment: 'jest-environment-jsdom',
  roots: ['<rootDir>/jest-suite'],
  transform: {
    '^.+\\.(t|j)sx?$': ['@swc/jest', { /* tsx + automatic runtime */ }],
  },
  moduleNameMapper: {
    '\\.module\\.css$': 'identity-obj-proxy',
    '\\.svg$': '<rootDir>/test/file-stub.cjs',
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  setupFilesAfterEnv: ['<rootDir>/test/setup.ts'],
};

First Jest run: 4 tests passed and one snapshot was written. Second run: 4 passed, nothing written. That's the baseline.

What Happens If You Only Swap the Runner?

Here's the ladder, each row cumulative with the ones above it. "Files with a problem" counts files that either failed an assertion or couldn't run at all.

StepChangePassedFailedFiles with a problem
0swap the runner, environment: 'jsdom', globals: true004
1resolve.alias for @/123
2@testing-library/jest-dom/vitest123
3jest. becomes vi.222
4css.modules.classNameStrategy: 'non-scoped'311
5delete the Jest snapshot, let Vitest write its own400

Step 0 is the one that stings in a PR. Zero tests failed, zero passed, and all four files died on Failed to resolve import "@/components/Counter". Vitest doesn't read your jest.config, so moduleNameMapper simply isn't there. If you only watch the failed-tests number, step 0 looks like a clean run.

Step 2 moved nothing, and that deserves a sentence. With globals: true, the unchanged import '@testing-library/jest-dom' registered its matchers and toBeInTheDocument passed at step 1. The Vitest migration page points out that globals are off by default and that Testing Library won't clean up the DOM without them. I switched to the /vitest entry anyway. One line, and it names the runner.

Why Did the Class Name Test Fail?

At step 1 the alias resolved, and counter.test.tsx failed on the assertion I'd have bet on:

expect(element).toHaveClass("counter")
Expected the element to have class:  counter
Received:  _counter_4a361d

Jest used identity-obj-proxy, which returns the key as the class name. Vitest, with its default config, handed back a hashed name in the _name_hash shape, which matches what the config reference calls the stable strategy. The reference page also says unprocessed CSS modules "return a proxy", and what I saw wasn't that. I'm reporting the output, not reconciling the two sentences.

The fix is one option and it doesn't need css.include:

// vitest.config.ts
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';

export default defineConfig({
  resolve: {
    alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
  },
  test: {
    environment: 'jsdom',
    globals: true,
    include: ['vitest-suite/**/*.test.{ts,tsx}'],
    setupFiles: ['./test/setup.vitest.ts'],
    css: { modules: { classNameStrategy: 'non-scoped' } },
  },
});

That's the whole config the suite ended on, and the repository runs it. Would I keep asserting on class names? Probably not. A test that checks toHaveClass('counter') is a test of your stylesheet's naming, and getByRole plus the visible text covers behavior without it. But a migration shouldn't also rewrite assertions, so I kept it and made the config match.

What Does jest.mock Cost?

Almost nothing, and the failure is loud. At step 1 usercard.test.tsx died with jest is not defined. A global regex fixes it:

perl -pe 's/\bjest\./vi./g' usercard.test.tsx

After that, vi.mock('@/api/user', () => ({ fetchUser: vi.fn() })) hoisted above the imports exactly as jest.mock did, and findByText('Ada') resolved. My factory returned only the named export the component imports, so the Vitest rule that a factory must define every export explicitly never bit. If yours mocks a module with a default export, it will. The Vitest 5 migration guide has that and the clearMocks default, which are behavior changes this suite deliberately doesn't trigger.

Which Break Didn't Happen?

I expected server-only.test.ts to be the casualty. It asks for the Node environment with a Jest docblock, and the Vitest environment guide documents @vitest-environment only. It passed at step 1, untouched, with the global config still saying jsdom.

A pass can lie, so I ran a control: same file, docblock deleted. It failed with expected 'object' to be 'undefined', which is document existing because jsdom took over. The docblock was honored. The docs don't promise that. My advice: leave the comments alone while you migrate, then rewrite them to // @vitest-environment node as a separate small commit, because a behavior nobody documented is a behavior somebody can remove.

What Was Wrong With the Snapshot?

From step 1 on, snapshot.test.tsx reported Snapshot ... mismatched, not "written" or "obsolete", so Vitest had read the file with the // Jest Snapshot v1 header and compared it. Step 4 fixed the class names, the snapshot still failed, and the only difference left was one attribute. Jest's file stub rendered the icon as src="test-file-stub". Vitest inlined the 12-pixel SVG as a data:image/svg+xml URL.

I deleted the old snapshot and let Vitest write its own. The new file carries // Vitest Snapshot v1 and the data URL, and the second run passed.

A snapshot that contains an asset URL is testing your bundler, not your component.

Share this Post on X Bluesky

That's why I'd want the snapshots out of a component suite during a migration. Every runner invents its own answer for what an import icon from './plus.svg' returns, so every runner change rewrites those lines, and a reviewer is asked to approve a wall of regenerated markup. Keep toMatchSnapshot for output that doesn't pass through the bundler.

jsdom or happy-dom?

I swapped the environment to happy-dom 21.0.3 as a last step and all four files passed. That's all this suite can tell you. The Vitest environment guide says happy-dom is faster and "lacks some API", and I didn't time either, so I won't pretend to know which is faster on your tests. If an import chain fails with unknown extension .css, the same guide points at server.deps.inline.

Run It Yourself

The folder is blogs/coding-dunia/examples/jest-to-vitest-react-components/. npm install, npm test. It runs Jest, runs the ladder above, runs the committed vitest.config.ts twice, and asserts every count in this article. If a future Vitest changes one of these answers, the script fails instead of this page quietly going stale. For the third option, a runner that needs no config at all, see the Node test runner comparison.

Frequently Asked Questions

What is the Vitest equivalent of Jest's moduleNameMapper?
For path aliases such as @/ it is resolve.alias in the Vitest config. In the suite behind this article, four test files could not run at all until I added resolve.alias with '@' pointing at src, because Vitest has no automatic reading of jest.config. For CSS modules there is no mapper to write: css.modules.classNameStrategy set to 'non-scoped' made class names come out unhashed.
Do Jest snapshot files work in Vitest?
Vitest read a snapshot file with the Jest header and compared it. In this suite the only difference was an SVG import, which Jest's file stub rendered as src="test-file-stub" and Vitest rendered as an inline data URL. After deleting the old file, Vitest wrote its own with a '// Vitest Snapshot v1' header and the test passed on the next run.
Does Vitest honor the @jest-environment docblock?
In Vitest 5.0.5 it did, even though the Vitest environment guide only documents @vitest-environment. A test file asking for node with an @jest-environment comment ran in Node while the config said jsdom. The same file with the comment removed failed with 'expected object to be undefined'. Because the docs don't promise it, rewrite the comment once the migration is done.
Do I have to change the jest-dom import for Vitest?
Not to get a green run with globals: true. The unchanged import '@testing-library/jest-dom' registered its matchers and toBeInTheDocument passed before I touched it, and switching to '@testing-library/jest-dom/vitest' moved no counts. I switched anyway because it is one line and names the runner.