CRA hides a ton of Jest configuration from you. You don't realize how much until it's gone and your tests start throwing "Cannot resolve module './Button.css'" errors everywhere. The CRA docs barely scratch the surface of what's actually happening behind the scenes.
What CRA Actually Does Behind the Scenes
CRA hides a bunch of Jest magic you never knew existed:
- Automatic jsdom setup with DOM globals
- CSS import stubbing (turns
import './styles.css'
into empty objects) - Image/SVG mocking (imports become string paths)
- setupTests.js auto-loading
- Global test functions without imports (
describe
,test
,expect
) - Custom Jest matchers from testing libraries
- Module mocking capabilities for Node modules
- Transform configurations for different file types
- Babel configuration hidden in react-scripts
- Coverage collection with sensible defaults
Switch to Vite and all of that vanishes instantly. Every component that imports CSS breaks. Every test using describe
without importing it fails.
The 5 Ways Your Tests Will Break
The first thing that breaks is environment variables. process.env.REACT_APP_API_URL
suddenly becomes undefined because Vite uses VITE_*
prefixes instead of React's REACT_APP_*
convention. Spent a couple hours figuring out why all our API mocks stopped working. You don't think to check this until half your tests are broken.
Then there's the CSS import nightmare:
Error: Cannot resolve module './Button.css'
This shows up everywhere. Jest stubbed CSS imports automatically, but Vitest requires explicit configuration to handle them. Every component that imports styles will throw this error.
Your setupTests.js file? Completely ignored. All those custom matchers, mock setups, polyfills - gone. Vitest needs explicit configuration to know about your setup file. The migration guide mentions this but doesn't emphasize how much shit will break when you miss it.
Jest injected test
, describe
, expect
globally, but Vitest makes this optional and it's disabled by default. So every test file breaks with "describe is not defined" errors. You can enable Vitest globals or import everything manually like a savage.
And image imports that worked fine in Jest:
import logo from './logo.svg'
Suddenly fail in Vitest unless you configure asset mocks. Jest turned these into strings automatically through CRA's file transform.
How This Actually Went
I made the mistake of trying to do everything at once. Figured I'd knock it out in a morning - ended up debugging import errors for the rest of the day. Tests that were passing suddenly broke with messages that told me absolutely nothing useful.
First hour: "This'll be easy, just swap Jest for Vitest."
Second hour: "Why the fuck is every CSS import broken?"
Third hour: "OK, found the CSS mock config..."
Fourth hour: "Now environment variables are undefined everywhere."
Fifth hour: "Jesus Christ, why are half the tests still failing?"
Installing Vitest takes 30 seconds. Getting it to work with your existing setup? That's where you lose the afternoon. Took me two full days because our staging CI was using different environment variable names than local development. Didn't figure that out until I ran the tests in a Docker container and watched them fail the exact same way as CI.
Config That Actually Works
The official migration docs cover the basics but skip over the stuff that actually breaks. This config worked for me after I fixed the ESM import bullshit and path resolution problems.
npm uninstall @testing-library/jest-dom
npm install --save-dev vitest @vitejs/plugin-react jsdom @testing-library/jest-dom
Create vitest.config.ts
:
/// <reference types=\"vitest\" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
globals: true,
},
})
The Setup File That Actually Works
Create src/test/setup.ts
and paste this:
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
afterEach(() => {
cleanup()
})
// Change these to whatever env vars your app actually uses
vi.stubEnv('VITE_API_URL', 'http://localhost:3001/api')
vi.stubEnv('VITE_APP_ENV', 'test')
// Polyfill bullshit for older Node versions
if (!global.fetch) {
global.fetch = vi.fn()
}
// ResizeObserver mock - everything uses this now apparently
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
// Mock all the asset bullshit - you get the idea
vi.mock('**/*.css', () => ({}))
vi.mock('**/*.scss', () => ({}))
vi.mock('**/*.png', () => ({ default: 'test-file-stub' }))
vi.mock('**/*.svg', () => ({
default: 'test-file-stub',
ReactComponent: vi.fn(() => null)
}))
// Add more as needed - jpeg, gif, whatever your app imports
This setup file replaces all the magic CRA did behind the scenes. Getting the environment variables right was way more annoying than expected - had to grep through the codebase multiple times to find all the ones we actually used. Missed a bunch the first time and spent hours figuring out why random tests were failing. Then I realized our staging environment was using different variable names than local development.
Pro tip: Don't trust your .env file. We had some genius who decided to use different variable names in production. REACT_APP_API_URL
locally became REACT_APP_API_ENDPOINT
in staging and API_URL
in production. Took me forever to track down why tests passed locally but failed in CI. The error was just "undefined API URL" - super helpful.
The goal is making Vitest behave enough like Jest that you don't have to touch every test file. Most guides assume you're starting fresh, but when you're migrating an existing codebase, you need this kind of compatibility layer.
Performance Reality Check
Independent benchmarks show mixed results - Vitest can be faster in watch mode but not always faster for full test runs. The real benefit is better TypeScript support and hot reload capabilities.
Don't migrate for speed alone - the configuration overhead might offset any performance gains for smaller projects. The wins are clearer with large TypeScript codebases where Jest's Babel transform becomes a bottleneck.