Logo

Jest vs Vitest: Architecture, Migration Gotchas, and When to Use Which

8 min read
vitestjesttesting

Table of Contents

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.ts
2import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
3import { defineConfig } from 'vitest/config'
4
5export 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 needed
2describe('myFunction', () => {
3 it('should work', () => {
4 expect(1 + 1).toBe(2)
5 })
6})
7
8// Vitest - explicit imports
9import { describe, it, expect } from 'vitest'
10
11describe('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:

  1. Jest reads your test file
  2. Transforms it using Babel (or ts-jest/SWC) based on your config
  3. Runs it in a sandboxed VM with its own module system
  4. 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:

  1. Vitest spins up Vite's transform pipeline
  2. Uses esbuild for TypeScript/JSX transformation (way faster than Babel)
  3. Leverages native ES modules
  4. 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:

  1. esbuild vs Babel: esbuild is written in Go and is 10-100x faster than Babel for transpilation
  2. Smart caching: Vite caches transformed modules, so re-runs only process changed files
  3. 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.js
2module.exports = {
3 moduleNameMapper: {
4 '^@/(.*)$': '<rootDir>/$1',
5 },
6}

In Vitest, it uses Vite's resolve.alias:

1// vitest.config.ts
2import path from 'node:path'
3import { defineConfig } from 'vitest/config'
4
5export 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.ts
2export default defineConfig({
3 test: {
4 isolate: true, // Isolate each test file
5 fileParallelism: false, // Run files sequentially
6 },
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 environment
7 },
8 },
9 },
10})

Snapshot Testing: Same Concept, Different Files

Both support snapshot testing with nearly identical syntax:

1// Works in both
2expect(component).toMatchSnapshot()
3expect(data).toMatchInlineSnapshot()

The difference is file format. Jest creates .snap files:

1// __snapshots__/myTest.test.ts.snap
2exports[`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'
2
3jest.mock('./myModule', () => ({
4 myFunction: jest.fn(() => 'mocked'),
5}))
6
7// This works because jest.mock is hoisted above the import

In Vitest:

1import { myFunction } from './myModule'
2
3vi.mock('./myModule', () => ({
4 myFunction: vi.fn(() => 'mocked'),
5}))
6
7// 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'
2
3jest.mock('./myModule', () => ({
4 myFunction: jest.fn(() => mockValue), // Error: mockValue is not defined
5}))

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}))
4
5vi.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// Jest
2jest.useFakeTimers()
3jest.advanceTimersByTime(1000)
4jest.runAllTimers()
5jest.useRealTimers()
6
7// Vitest
8vi.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'))
3
4expect(new Date().getFullYear()).toBe(2024)
5
6vi.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:

  1. Next.js without Storybook: The next/jest package handles all the configuration automatically. Zero setup.
  2. Existing large Jest codebase: Migration has a cost, and Jest works fine.
  3. 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.

Related Articles