If you're starting a new React + TypeScript project on Vite and reaching for Jest out of habit, stop. Vitest is the correct choice. It shares Vite's config, supports TypeScript natively, runs tests in parallel by default, and eliminates the babel-jest/ts-jest headache entirely. This guide covers the full vitest setup for a React TypeScript project — from bare scaffold to working coverage reports — in under ten minutes.
If you're migrating from Jest, there's a dedicated section below. That's where most of the pain lives.
Install and Configure Vitest
Start from a fresh Vite scaffold if you haven't already:
npm create vite@latest my-app -- --template react-ts
cd my-app
npm installAdd Vitest and the testing utilities:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomNo babel.config.js. No jest.config.ts. No @types/jest. That's the complete dependency list.
Open vite.config.ts and add a test block. The entire Vitest config lives here — not in a separate file:
/// <reference types="vitest" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
css: true,
},
})The /// <reference types="vitest" /> triple-slash directive at the top is required for TypeScript to recognise the test property on defineConfig. The docs don't make this obvious — it took me 20 minutes to figure out why TS was complaining about an unknown property on an otherwise valid config object.
globals: true makes describe, it, expect, and vi available without imports. In larger projects, set it to false and import from vitest explicitly — it's cleaner and avoids IDE ambiguity about which global you mean.
Create the setup file:
// src/test/setup.ts
import '@testing-library/jest-dom'This registers the extended matchers: toBeInTheDocument(), toHaveValue(), toBeDisabled(), and the rest. Without this file, environment: 'jsdom' gives you the DOM but not the matcher extensions.
Add the scripts to package.json:
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage"
}Write a quick sanity-check test:
// src/components/Counter.tsx
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(c => c + 1)}>
Count: {count}
</button>
)
}// src/components/Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
it('increments count on click', async () => {
render(<Counter />)
const button = screen.getByRole('button', { name: /count: 0/i })
await userEvent.click(button)
expect(screen.getByText('Count: 1')).toBeInTheDocument()
})Run npm test. Vitest starts in watch mode and your test passes. That's a fully working vitest jsdom environment in React TypeScript.
Coverage with @vitest/coverage-v8
Install the coverage provider:
npm install -D @vitest/coverage-v8Extend the test block in vite.config.ts:
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/test/**', 'src/main.tsx', 'src/vite-env.d.ts'],
},
}Run npm run test:coverage. Output lands in ./coverage. The lcov format is what CI coverage tools (Codecov, Coveralls) consume. The HTML report at coverage/index.html is the one worth opening locally — the terminal summary tells you percentages, but the HTML view shows exactly which branches you're missing, which is what actually matters when you're trying to improve a number.
Where Jest Migrants Get Tripped Up
Migrating from Jest to Vitest is 90% a namespace rename. Here's the 10% that will eat your afternoon.
jest.fn() → vi.fn(). All mock APIs live under the vi namespace. A global find-and-replace of jest. → vi. handles most cases. Watch for type imports separately — jest.Mock becomes import type { Mock } from 'vitest'.
Remove @types/jest completely. If you leave it installed alongside Vitest with globals: true, TypeScript sees duplicate global declarations and type errors start appearing that have nothing to do with your actual code. Delete the package and remove it from your tsconfig.json types array if it's there explicitly. This one causes Stack Overflow rabbit holes because the errors look like Vitest bugs.
The config property rename. Jest uses testEnvironment: 'jsdom'. Vitest uses environment: 'jsdom'. Copy-paste a Jest config and wonder why your DOM APIs are undefined — that's why. The docs for both show their own property names without flagging the difference.
Timer mocks are vi.useFakeTimers(). Same API, different namespace. vi.advanceTimersByTime(), vi.runAllTimers(), vi.clearAllTimers() — identical patterns, just swap the prefix.
Module mocking still auto-hoists. vi.mock() gets hoisted to the top of the file automatically, exactly like jest.mock(). The behaviour is identical; only the name changes.
For larger codebases, keep globals: true during migration — most Jest tests run unmodified. Clean up the vi. namespace iteratively rather than all at once and fighting a wall of red.
TypeScript Path Aliases Just Work
If your project uses path aliases — @/components pointing to ./src/components — Vitest inherits them automatically from Vite's resolve.alias config. No moduleNameMapper to maintain. Single config, no drift between build and test environments.
// vite.config.ts — alias applies to both build AND tests
export default defineConfig({
resolve: {
alias: { '@': path.resolve(__dirname, './src') },
},
// ...rest of config
})With Jest you had to mirror this in moduleNameMapper and often in tsconfig.json paths as well — three places to keep in sync, one of which would inevitably drift. This single-config behaviour is one of the concrete wins of Vitest for Vite-based projects.
CI Integration
In CI, use vitest run — it exits after one run instead of staying in watch mode:
# .github/workflows/test.yml
- name: Test
run: npx vitest run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.infoIf you're deploying to Vercel — a natural fit for Vite apps given its zero-config build detection — you can gate deployments on test failures by running tests in a GitHub Actions check that Vercel requires to pass before promoting a deployment.
For production error visibility, Sentry integrates with Vite via @sentry/vite-plugin and uploads source maps automatically at build time, so production stack traces point at your original TypeScript rather than minified output.
The Vitest UI (Optional)
Install and run the browser-based test explorer:
npm install -D @vitest/ui
npm run test:uiVitest opens a UI at localhost:51204 showing test results, the module dependency graph, and inline coverage. It's genuinely useful for debugging complex test failures — seeing assertion output and the component tree side by side beats scrolling terminal output. Not required for a working setup, but worth knowing it exists.
The Verdict
Vitest setup in a React TypeScript project is meaningfully simpler than Jest. Fewer packages, no transform config, TypeScript support without ts-jest, and watch mode that's actually fast on large test suites. The migration from Jest is mostly mechanical — the real investment is unlearning the assumption that testing requires a separate config file.
If you're building out the full React stack from scratch, the 2026 React project setup guide walks through Vite + Vitest + Biome + TanStack Router as a cohesive stack with the tradeoffs explained at each step. For monorepo setups, the pnpm workspaces and Biome guide covers sharing a single Vitest config across packages without duplicating setup files in every workspace.
One config file. No Babel. npm test. Done.