I've been using Jest for years. It's reliable, well-documented, and just works with most setups. But I recently set up Storybook in a Next.js project, and it came with Vitest pre-configured for component testing. Instead of maintaining two test runners, I figured I'd try Vitest for my unit tests too.
The Setup That Led Me Here
My project is a Next.js app with Storybook. When I ran npx storybook@latest init, it automatically set up Vitest with the @storybook/addon-vitest plugin. The config looked like this:
1// vitest.config.ts2import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'3import { defineConfig } from 'vitest/config'45export default defineConfig({6 test: {7 projects: [8 {9 extends: true,10 plugins: [storybookTest({ configDir: '.storybook' })],11 test: {12 name: 'storybook',13 browser: {14 enabled: true,15 headless: true,16 },17 },18 },19 ],20 },21})
Since Vitest was already there, adding unit tests was just a matter of adding another project:
1{2 extends: true,3 test: {4 name: 'unit',5 include: ['**/*.test.ts'],6 environment: 'node',7 },8}
With Jest, I would've needed a separate jest.config.js, plus ts-jest or SWC transforms. Having one config for both Storybook and unit tests felt cleaner.
Syntax: Almost Identical
The test syntax is nearly the same. The main difference is that Vitest requires explicit imports:
1// Jest - globals, no imports needed2describe('myFunction', () => {3 it('should work', () => {4 expect(1 + 1).toBe(2)5 })6})78// Vitest - explicit imports9import { describe, it, expect } from 'vitest'1011describe('myFunction', () => {12 it('should work', () => {13 expect(1 + 1).toBe(2)14 })15})
Vitest can use globals too if you set globals: true in the config, but I prefer explicit imports. It's clearer what's being used and plays nicer with TypeScript.
How They Work Under the Hood
This is where things get interesting.
Jest's Architecture
Jest uses its own transform pipeline. When you run a test:
- Jest reads your test file
- Transforms it using Babel (or ts-jest/SWC) based on your config
- Runs it in a sandboxed VM with its own module system
- Each test file gets its own isolated environment
Jest was built before native ES modules were widely supported, so it implemented its own module system. This is why you sometimes run into weird ESM issues with Jest, like needing transformIgnorePatterns for node_modules that ship ES modules.
Vitest's Architecture
Vitest uses Vite's dev server pipeline:
- Vitest spins up Vite's transform pipeline
- Uses esbuild for TypeScript/JSX transformation (way faster than Babel)
- Leverages native ES modules
- Shares the same module graph and caching as Vite
Because Vite was built with ESM in mind, there's no fighting with module systems. TypeScript and JSX work out of the box without extra configuration.
The Speed Difference
Vitest is noticeably faster, especially on larger test suites. The reasons:
- esbuild vs Babel: esbuild is written in Go and is 10-100x faster than Babel for transpilation
- Smart caching: Vite caches transformed modules, so re-runs only process changed files
- Native ESM: No overhead from simulating a module system
For my small test suite (around 50 tests), the difference is marginal. But I've seen benchmarks where Vitest is 2-3x faster on larger codebases.
Key Differences When Migrating
If you're moving from Jest to Vitest, these are the things that will trip you up.
Path Aliases: moduleNameMapper vs resolve.alias
In Jest, path aliases like @/ are configured with moduleNameMapper:
1// jest.config.js2module.exports = {3 moduleNameMapper: {4 '^@/(.*)$': '<rootDir>/$1',5 },6}
In Vitest, it uses Vite's resolve.alias:
1// vitest.config.ts2import path from 'node:path'3import { defineConfig } from 'vitest/config'45export default defineConfig({6 resolve: {7 alias: {8 '@': path.resolve(__dirname),9 },10 },11})
The Vitest approach is cleaner since it's just standard Vite config. If you're already using Vite for your app, the alias is probably already defined and Vitest inherits it automatically.
Test Isolation: Different Defaults
Jest runs each test file in a separate process by default. You can disable this with --runInBand to run everything in a single process (useful for debugging).
Vitest does the opposite. By default, it runs tests in a single process using worker threads. You can enable full isolation with:
1// vitest.config.ts2export default defineConfig({3 test: {4 isolate: true, // Isolate each test file5 fileParallelism: false, // Run files sequentially6 },7})
Why does this matter? In Jest, if one test file pollutes global state, it won't affect other files. In Vitest, without isolate: true, global state can leak between test files. This is worth knowing if your tests share global state.
There's also poolOptions.threads.isolate for more granular control:
1export default defineConfig({2 test: {3 pool: 'threads',4 poolOptions: {5 threads: {6 isolate: true, // Each thread gets fresh environment7 },8 },9 },10})
Snapshot Testing: Same Concept, Different Files
Both support snapshot testing with nearly identical syntax:
1// Works in both2expect(component).toMatchSnapshot()3expect(data).toMatchInlineSnapshot()
The difference is file format. Jest creates .snap files:
1// __snapshots__/myTest.test.ts.snap2exports[`should render correctly 1`] = `"<div>Hello</div>"`;
Vitest uses the same format by default, so existing snapshots work. But Vitest also supports inline snapshots that update the source file directly:
1expect(data).toMatchInlineSnapshot(`2 {3 "name": "test",4 "value": 123,5 }6`)
When you run vitest -u, it updates the snapshot right in your test file. I find inline snapshots more readable for small objects since you don't have to open a separate file.
Mock Hoisting: jest.mock vs vi.mock
This is where things get tricky. Both Jest and Vitest hoist mock declarations to the top of the file, but they behave differently.
In Jest:
1import { myFunction } from './myModule'23jest.mock('./myModule', () => ({4 myFunction: jest.fn(() => 'mocked'),5}))67// This works because jest.mock is hoisted above the import
In Vitest:
1import { myFunction } from './myModule'23vi.mock('./myModule', () => ({4 myFunction: vi.fn(() => 'mocked'),5}))67// Also works - vi.mock is hoisted too
The gotcha is with variables. In Jest, you can't use variables defined in the same file inside jest.mock because of hoisting:
1const mockValue = 'test'23jest.mock('./myModule', () => ({4 myFunction: jest.fn(() => mockValue), // Error: mockValue is not defined5}))
You have to use jest.doMock for dynamic mocks, or define the variable inside the mock factory.
Vitest has the same limitation, but provides vi.hoisted() to handle it:
1const { mockValue } = vi.hoisted(() => ({2 mockValue: 'test',3}))45vi.mock('./myModule', () => ({6 myFunction: vi.fn(() => mockValue), // Works!7}))
The vi.hoisted() function runs before imports, so the variable is available in the mock factory. It's more explicit than Jest's approach.
Timer Mocks
Both have fake timers, but the API differs slightly:
1// Jest2jest.useFakeTimers()3jest.advanceTimersByTime(1000)4jest.runAllTimers()5jest.useRealTimers()67// Vitest8vi.useFakeTimers()9vi.advanceTimersByTime(1000)10vi.runAllTimers()11vi.useRealTimers()
Straightforward find-and-replace. But Vitest also supports vi.setSystemTime() for mocking Date:
1vi.useFakeTimers()2vi.setSystemTime(new Date('2024-01-01'))34expect(new Date().getFullYear()).toBe(2024)56vi.useRealTimers()
Jest requires additional setup with @sinonjs/fake-timers or manual Date mocking for this.
Interesting Facts
A few things I learned while digging into this:
Jest's Name Origin
Jest was created at Facebook (now Meta) in 2014. The name comes from the idea that testing should be "delightful" and not feel like a chore, like a jester making things fun.
Vitest's Relationship with Vite
Vitest was created by Anthony Fu (a core Vite team member) in 2021. It started as a proof of concept to see if Vite's transform pipeline could be used for testing. Turns out it could, and it was fast.
Compatibility Mode
Vitest has a Jest compatibility mode that makes migration easier. It can even read jest.config.js files with some configuration. The API is intentionally similar because most developers already know Jest.
Watch Mode by Default
Running vitest starts watch mode by default, while Jest requires jest --watch. I actually prefer this, since during development I'm almost always watching for changes.
When I'd Still Use Jest
Despite liking Vitest, there are cases where Jest makes more sense:
- Next.js without Storybook: The
next/jestpackage handles all the configuration automatically. Zero setup. - Existing large Jest codebase: Migration has a cost, and Jest works fine.
- Teams unfamiliar with Vite: Jest has more Stack Overflow answers and documentation.
My Takeaway
Vitest isn't a revolutionary change in how I write tests. The syntax is the same, the assertions are the same. But the setup is simpler when you're already in the Vite ecosystem, and the speed is a nice bonus.
If you're starting a new Vite or Storybook project, Vitest is the obvious choice. If you're on Next.js with no Vite tooling, Jest with next/jest is still the path of least resistance.
For me, having Storybook already set up with Vitest made the decision easy. One test runner, one config, and it just works.
