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.tovi.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 nodekept 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?
| File | What it exercises |
|---|---|
counter.test.tsx | user-event click, jest-dom matchers, a CSS module class, the @/ alias |
usercard.test.tsx | jest.mock with a factory on an aliased module, findByText |
snapshot.test.tsx | toMatchSnapshot 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.
| Step | Change | Passed | Failed | Files with a problem |
|---|---|---|---|---|
| 0 | swap the runner, environment: 'jsdom', globals: true | 0 | 0 | 4 |
| 1 | resolve.alias for @/ | 1 | 2 | 3 |
| 2 | @testing-library/jest-dom/vitest | 1 | 2 | 3 |
| 3 | jest. becomes vi. | 2 | 2 | 2 |
| 4 | css.modules.classNameStrategy: 'non-scoped' | 3 | 1 | 1 |
| 5 | delete the Jest snapshot, let Vitest write its own | 4 | 0 | 0 |
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.
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.