Vitest 4 — Testing Framework 28x Faster Than Jest for Vue and Vite
Posted on: 4/26/2026 1:13:24 PM
Table of contents
- 1. What is Vitest and Why It Matters
- 2. Vitest Architecture Internals
- 3. Vitest 4.0 — New Features
- 4. Benchmark: Vitest vs Jest — Real Numbers
- 5. Integrating Vitest with Vue 3
- 6. Browser Mode — Testing Components in Real Browsers
- 7. Migrating from Jest to Vitest
- 8. CI/CD Integration
- 9. Best Practices with Vitest
- 10. Conclusion
1. What is Vitest and Why It Matters
Vitest is a next-generation testing framework built on top of Vite — the build tool dominating the frontend ecosystem. Instead of wrestling with complex configurations involving babel, ts-jest, and numerous plugins as with Jest, Vitest directly leverages Vite's transform pipeline: TypeScript, JSX, and CSS Modules all work out of the box with zero configuration.
With version 4.0 released in 2026, Vitest marks a major milestone as Browser Mode officially becomes stable, Visual Regression Testing is built in, and Playwright Traces make debugging failed tests more visual than ever.
Why switch from Jest to Vitest?
Jest was designed for the CommonJS era. Even though Jest 30 has improved ESM support, configuration remains complex — you need ts-jest or babel-jest for TypeScript, moduleNameMapper for path aliases, and separate CSS mocking. Vitest inherits the entire config from vite.config.ts, so all aliases, plugins, and transforms work consistently between the dev server and test runner.
2. Vitest Architecture Internals
Vitest isn't simply "Jest running on Vite." Its architecture is redesigned from scratch to fully leverage native ESM and Hot Module Replacement:
graph TB
A["vite.config.ts
Shared Config"] --> B["Vite Dev Server
Transform Pipeline"]
B --> C["Vitest Runner
Test Scheduler"]
C --> D["Worker Pool
tinypool threads"]
C --> E["Browser Mode
Playwright / WebdriverIO"]
D --> F["Node.js Tests
Unit & Integration"]
E --> G["Real Browser Tests
Component & E2E"]
B --> H["HMR Watcher
File Change Detection"]
H --> C
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style C fill:#2c3e50,stroke:#fff,color:#fff
style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style F fill:#f8f9fa,stroke:#e0e0e0,color:#555
style G fill:#f8f9fa,stroke:#e0e0e0,color:#555
style H fill:#16213e,stroke:#fff,color:#fff
Vitest architecture: leveraging Vite's transform pipeline and HMR for blazing fast watch mode
When you change a file, Vite HMR pinpoints exactly which modules are affected. Vitest only re-runs tests related to those modules — no need to re-parse the entire dependency graph like Jest. This is why watch mode is 28x faster.
3. Vitest 4.0 — New Features
3.1. Browser Mode is now stable
After multiple experimental releases, Browser Mode in Vitest 4.0 has exited experimental status. Instead of testing components in jsdom (a simulated DOM environment), you run tests in a real browser via Playwright or WebdriverIO:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
browser: {
enabled: true,
instances: [
{ browser: 'chromium' },
{ browser: 'firefox' },
{ browser: 'webkit' }
]
}
}
})
Providers are now separate packages:
@vitest/browser-playwright— for Playwright@vitest/browser-webdriverio— for WebdriverIO@vitest/browser-preview— for preview mode
3.2. Built-in Visual Regression Testing
Vitest 4.0 introduces the toMatchScreenshot assertion — enabling screenshot comparison directly in tests without external libraries:
import { test, expect } from 'vitest'
import { page } from 'vitest/browser'
test('hero section renders correctly', async () => {
await page.goto('/landing')
await expect(
page.getByTestId('hero-section')
).toMatchScreenshot('hero-section')
})
test('element is visible in viewport', async () => {
await expect(
page.getByRole('banner')
).toBeInViewport({ ratio: 0.8 })
})
Practical benefit
Visual Regression Testing catches UI changes that unit tests can't: fonts being swapped, spacing off by 2px, colors wrong due to CSS specificity. Before Vitest 4, you needed separate tools like Percy, Chromatic, or complex jest-image-snapshot configurations.
3.3. Playwright Traces
When browser tests fail, debugging is often difficult because you don't know the DOM state at the moment of failure. Vitest 4.0 integrates Playwright Traces — recording the entire interaction timeline:
# Enable traces on first retry
npx vitest --browser.trace=on-first-retry
# Enable traces on all retries
npx vitest --browser.trace=on-all-retries
# Only keep traces when tests fail
npx vitest --browser.trace=retain-on-failure
Traces appear as annotations in reporters, and you can open them in Playwright Trace Viewer to inspect each step: clicks, navigation, network requests, DOM snapshots.
3.4. Schema Matching with Zod, Valibot, ArkType
The new expect.schemaMatching feature validates returned data against Standard Schema v1:
import { z } from 'zod'
import { expect, test } from 'vitest'
const UserSchema = z.object({
id: z.number().positive(),
email: z.string().email(),
role: z.enum(['admin', 'user', 'editor'])
})
test('API returns valid user', async () => {
const response = await fetch('/api/user/1')
const user = await response.json()
expect(user).toEqual({
id: expect.any(Number),
email: expect.schemaMatching(z.string().email()),
role: expect.schemaMatching(z.enum(['admin', 'user', 'editor']))
})
})
3.5. Type-Aware Fixtures
Vitest 4.0 extends test fixtures with type-safe lifecycle hooks:
import { test as base } from 'vitest'
const test = base.extend<{ todos: string[] }>({
todos: async ({}, use) => {
const list: string[] = []
await use(list)
// cleanup after test
}
})
// beforeEach has full type inference
test.beforeEach(({ todos }) => {
todos.push('initial item')
})
test('add todo', ({ todos }) => {
todos.push('new item')
expect(todos).toHaveLength(2)
})
4. Benchmark: Vitest vs Jest — Real Numbers
Benchmarks performed on the same 500 test files suite, running Node.js 22, 16GB RAM machine:
| Metric | Vitest 3/4 | Jest 30 | Difference |
|---|---|---|---|
| Cold start (500 files) | ~38 seconds | ~214 seconds | 5.6x faster |
| Watch mode re-run | ~0.3 seconds | ~8.4 seconds | 28x faster |
| Peak memory | ~400 MB | ~930 MB | 57% less |
| Native ESM | Yes | Partial support | — |
| TypeScript | Native via Vite | Requires ts-jest/babel | Zero config |
| Browser testing | Playwright/WebdriverIO | jsdom only | Real browser |
| CSS Modules | Automatic via Vite | Requires moduleNameMapper | Zero config |
graph LR
subgraph Jest["Jest 30"]
J1["Source File"] --> J2["babel-jest
Transform"]
J2 --> J3["ts-jest
TypeScript"]
J3 --> J4["moduleNameMapper
Aliases"]
J4 --> J5["jsdom
DOM Simulation"]
end
subgraph Vitest["Vitest 4"]
V1["Source File"] --> V2["Vite Pipeline
TS + JSX + CSS"]
V2 --> V3["Real Browser
or Node.js"]
end
style J1 fill:#f8f9fa,stroke:#e0e0e0,color:#555
style J2 fill:#f8f9fa,stroke:#e0e0e0,color:#555
style J3 fill:#f8f9fa,stroke:#e0e0e0,color:#555
style J4 fill:#f8f9fa,stroke:#e0e0e0,color:#555
style J5 fill:#f8f9fa,stroke:#e0e0e0,color:#555
style V1 fill:#e94560,stroke:#fff,color:#fff
style V2 fill:#e94560,stroke:#fff,color:#fff
style V3 fill:#e94560,stroke:#fff,color:#fff
Transform pipeline: Jest needs multiple plugin layers, Vitest uses Vite directly
5. Integrating Vitest with Vue 3
5.1. Basic setup
# Installation
npm install -D vitest @vue/test-utils @vitejs/plugin-vue happy-dom
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov']
}
}
})
5.2. Component Testing
// TodoList.test.ts
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import TodoList from './TodoList.vue'
describe('TodoList', () => {
it('renders todos from props', () => {
const wrapper = mount(TodoList, {
props: {
items: ['Learn Vitest', 'Write tests']
}
})
expect(wrapper.findAll('li')).toHaveLength(2)
expect(wrapper.text()).toContain('Learn Vitest')
})
it('emits add event on form submit', async () => {
const wrapper = mount(TodoList, {
props: { items: [] }
})
await wrapper.find('input').setValue('New todo')
await wrapper.find('form').trigger('submit')
expect(wrapper.emitted('add')).toHaveLength(1)
expect(wrapper.emitted('add')![0]).toEqual(['New todo'])
})
})
5.3. Testing Composables
// useCounter.test.ts
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'
describe('useCounter', () => {
it('increments count', () => {
const { count, increment } = useCounter()
expect(count.value).toBe(0)
increment()
expect(count.value).toBe(1)
})
it('respects max limit', () => {
const { count, increment } = useCounter({ max: 2 })
increment()
increment()
increment() // should not exceed max
expect(count.value).toBe(2)
})
})
5.4. Testing Pinia Stores
// userStore.test.ts
import { setActivePinia, createPinia } from 'pinia'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useUserStore } from './userStore'
describe('User Store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('fetches user profile', async () => {
const store = useUserStore()
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify({
name: 'Anh Tu',
role: 'admin'
}))
)
await store.fetchProfile()
expect(store.user?.name).toBe('Anh Tu')
expect(store.isAdmin).toBe(true)
})
})
6. Browser Mode — Testing Components in Real Browsers
The biggest difference between jsdom and Browser Mode: jsdom has no layout engine. APIs like getBoundingClientRect(), IntersectionObserver, ResizeObserver, and CSS animations don't work correctly in jsdom. Browser Mode completely solves this.
// vitest.config.ts — Browser Mode for Vue
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
browser: {
enabled: true,
instances: [
{ browser: 'chromium', provider: 'playwright' }
]
}
}
})
// Dialog.browser.test.ts
import { test, expect } from 'vitest'
import { page } from 'vitest/browser'
import { render } from 'vitest-browser-vue'
import Dialog from './Dialog.vue'
test('dialog has correct position and animation', async () => {
const screen = render(Dialog, {
props: { open: true, title: 'Confirm' }
})
const dialog = screen.getByRole('dialog')
// Real browser APIs work!
await expect(dialog).toBeInViewport({ ratio: 1.0 })
// Visual regression test
await expect(dialog).toMatchScreenshot('dialog-open')
})
Important note
Browser Mode requires Playwright installation: npx playwright install. In CI, add a browser installation step to your pipeline. Browser tests are slower than pure Node.js unit tests, so only use them for components that genuinely need a real DOM (animations, layout, visual regression).
7. Migrating from Jest to Vitest
Vitest's API is designed to be nearly 100% compatible with Jest. Migration typically takes just a few steps:
npm install -D vitest and remove jest, ts-jest, babel-jest, @types/jest from dependencies.
jest.config.js. Add a test block to vite.config.ts or create vitest.config.ts. No need for moduleNameMapper — Vite aliases work automatically.
jest.fn() → vi.fn(), jest.mock() → vi.mock(), jest.spyOn() → vi.spyOn(). Or enable globals: true to use without imports.
"test": "jest" → "test": "vitest" in package.json. Add "test:ui": "vitest --ui" for a beautiful web interface.
npx vitest run. Snapshot format is Jest-compatible so no updates needed. Coverage switches to v8 (faster than istanbul).
// The only import change needed:
- import { jest } from '@jest/globals'
+ import { vi } from 'vitest'
// API is nearly identical:
- const mockFn = jest.fn()
+ const mockFn = vi.fn()
- jest.spyOn(api, 'fetchUser')
+ vi.spyOn(api, 'fetchUser')
- jest.useFakeTimers()
+ vi.useFakeTimers()
8. CI/CD Integration
Vitest supports multiple reporter formats for CI:
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
# Unit tests
- run: npx vitest run --reporter=junit --outputFile=results.xml
# Coverage
- run: npx vitest run --coverage
# Browser tests (requires Playwright)
- run: npx playwright install --with-deps chromium
- run: npx vitest run --project=browser
Workspace Mode for Monorepos
Vitest 4.0 allows configuring workspaces directly in vitest.config.ts instead of a separate file:
export default defineConfig({
test: {
workspace: [
{ test: { name: 'unit', environment: 'node' } },
{ test: { name: 'browser', browser: { enabled: true } } }
]
}
})
9. Best Practices with Vitest
| Practice | Do | Avoid |
|---|---|---|
| Naming | *.test.ts or *.spec.ts next to source file |
Separate __tests__ folder far from source |
| Mocking | vi.mock() for external modules, vi.spyOn() for methods |
Mocking everything — tests lose meaning |
| Environment | happy-dom (2-3x faster than jsdom) |
jsdom when you don't need full browser compat |
| Coverage | Provider v8 (native, fast) |
istanbul (slower, requires instrumentation) |
| Browser tests | Only for components needing real DOM | All tests in browser — slows CI |
10. Conclusion
Vitest 4.0 isn't just "faster Jest" — it's a testing framework designed for the modern ecosystem with native ESM, zero-config TypeScript, and Browser Mode that actually works. With Vue 3, Nuxt 4, and Vite as the standard, Vitest is the most natural and efficient testing choice.
If you're starting a new project, Vitest is the default — no debate needed. If you're currently using Jest, migration takes about 30 minutes for most projects — and you'll immediately feel the difference with near-instant watch mode.
References:
Vitest 4.0 Official Blog ·
Vitest 3 vs Jest 30: Testing in 2026 — PkgPulse ·
Vitest in 2026 — DEV Community ·
Vitest GitHub Repository
Progressive Web Apps 2026: Offline-First with Service Worker, Workbox and Vue.js
Cloudflare D1 — Serverless SQL Database on the Edge
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.