Cách migrate từ Jest sang Vitest: hướng dẫn từng bước
Vitest chạy nhanh hơn Jest 28× trong watch mode. Hướng dẫn migrate đầy đủ: cài đặt, đổi tên globals, xử lý config files, và khắc phục sáu gotchas thường gặp.
Bởi Ethan · Cập nhật 20 tháng 5, 2026
2.404 từ · 13 phút đọc
Nếu Jest suite của bạn đang chạy tốt và CI đủ nhanh, đừng migrate. Nếu bạn đang mất thời gian thực chờ cold start, vật lộn với TypeScript transform config, hay nhìn watch mode chạy ì ạch sau mỗi lần sửa file — hướng dẫn này giúp bạn chuyển sang Vitest trong vòng một ngày.
Vitest 4.0 (phát hành ngày 22 tháng 10 năm 2025) đã ổn định, API tương thích 90% với Jest, và những con số này là thật: cold start nhanh hơn 5.6×, watch mode nhanh hơn 28× trên một production monorepo 50.000 test. Một codemod tự động xử lý phần lớn công việc đổi tên. Phần thủ công là config files và một số điểm khác biệt về hành vi mà codemod không bắt được.
Bài viết này dành cho ai
Dành cho các developer có Jest suite hiện tại trên dự án TypeScript — dùng Vite hoặc không. Nếu bạn đang làm React Native, dừng ở đây: Vitest không hỗ trợ React Native runtime và điều này sẽ không thay đổi sớm. Nếu bạn đang dùng Angular với hệ sinh thái Jest plugin lớn, hãy đọc phần Kết luận trước.
Nếu bạn chưa chắc có nên chuyển không, hãy đọc bài so sánh Vitest vs Jest của chúng tôi trước — bài đó đánh giá các tradeoffs mà không giả định bạn sẽ migrate.
Tại sao nên migrate từ Jest sang Vitest ngay bây giờ
Ba lý do để làm ngay hôm nay thay vì để sau.
Hiệu năng không phải là lời quảng cáo. Một production monorepo với 50.000 test đã giảm cold start từ 214 giây xuống còn 38 giây và watch re-run từ 8.4 giây xuống 0.3 giây sau khi migrate. Những con số này đến từ một case study được công bố của 3 kỹ sư đã migrate 82 test file trong 2 tuần — và chỉ phải tắt 1 test trong quá trình đó. Thời gian CI giảm từ 14 phút xuống dưới 5 phút.
Hệ sinh thái đã chuyển dịch. State of JS 2024 xếp Vitest đứng đầu về cả retention lẫn interest trong nhóm các testing library, với Jest bị xếp dưới ở cả hai tiêu chí. Vitest là test runner được khuyến nghị trong tài liệu chính thức của Vue, Nuxt, SvelteKit, và Astro. Lượt tải npm hàng tuần tăng từ 4.8 triệu ở v2.0 lên 7.7 triệu ở v3.0.
Không cần cấu hình TypeScript. Jest vẫn cần ts-jest hoặc babel-jest với Babel preset để hiểu TypeScript. Vitest dùng esbuild ngay từ đầu — không cần transform config, không cần preset, không cần Babel. Vitest đọc tsconfig.json của bạn mà không cần bất kỳ cấu hình transform nào thêm.
Yêu cầu trước khi bắt đầu
- Node 18+ (yêu cầu của Vitest 4 — kiểm tra bằng
node -v) - Một Jest test suite hiện có (CJS, ESM, hoặc hỗn hợp — đều được)
- Dự án dùng Vite sẽ hưởng lợi nhiều nhất; dự án không dùng Vite vẫn được lợi nhưng cần một
vitest.config.tsriêng
Bước 1: Cài đặt và cấu hình
Gỡ các package Jest, cài Vitest:
# Gỡ Jest
npm uninstall jest @types/jest babel-jest jest-environment-jsdom ts-jest
# Cài Vitest + các package đi kèm
npm install -D vitest @vitest/coverage-v8 jsdom
# Với dự án React thêm:
npm install -D @testing-library/react @testing-library/jest-dom
Tạo vitest.config.ts ở thư mục gốc của project:
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true, // cho phép dùng Vitest globals không cần import (vi, describe, expect, v.v.)
environment: 'jsdom', // hoặc 'happy-dom' để tăng tốc 2-4× (kiểm tra tương thích trước)
setupFiles: ['./vitest.setup.ts'],
coverage: {
provider: 'v8', // nhanh hơn; chuyển sang 'istanbul' nếu con số coverage có vẻ sai
reporter: ['text', 'html', 'lcov'],
},
},
})
Sau đó tạo vitest.setup.ts:
// vitest.setup.ts
// Với @testing-library/jest-dom v6+, dùng entry point dành riêng cho Vitest:
import '@testing-library/jest-dom/vitest'
Khi gặp lỗi: nếu bạn thấy Cannot find module '@vitejs/plugin-react', hãy cài riêng — package này không được bundle cùng Vitest. Dự án không dùng React có thể bỏ hoàn toàn mảng plugins. Dự án không dùng Vite (chưa có vite.config.ts) thì tạo vitest.config.ts như trên; dự án đã dùng Vite có thể gộp block test thẳng vào vite.config.ts hiện có.
Bước 2: Cập nhật test scripts
Thay thế các Jest scripts trong package.json:
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}
vitest run chạy một lần — tương đương jest không có flag. vitest (không có run) là watch mode, với khả năng phát hiện thay đổi theo HMR, chỉ chạy lại các test file bị ảnh hưởng. Flag --ui mở một interactive runner trên trình duyệt; cài @vitest/ui riêng nếu bạn muốn dùng.
Bước 3: Xử lý Jest globals
Khi globals: true trong config, các global của Vitest — describe, it, test, expect, vi, beforeAll, afterAll, beforeEach, afterEach — đều dùng được mà không cần import. Tuy nhiên, namespace jest không có sẵn, kể cả khi đã bật globals: true. Tài liệu migration của Vitest nói rõ: “Vitest doesn’t have an equivalent to jest namespace, so you will need to import types directly from vitest.” Bất kỳ lệnh gọi jest.* nào còn sót lại trong codebase sẽ ném lỗi ReferenceError: jest is not defined. Bạn cần chạy codemod dưới đây để chuyển đổi chúng.
Chạy codemod tự động trước:
npx codemod jest/vitest
Codemod này chuyển jest.fn() → vi.fn(), jest.spyOn() → vi.spyOn(), và thêm import { vi } from 'vitest' khi cần. Nó xử lý khoảng 80% trường hợp. Mock factory phức tạp và type import cần kiểm tra thủ công.
Bảng cheat sheet đổi tên đầy đủ:
| Jest | Vitest | Ghi chú |
|---|---|---|
jest.fn() | vi.fn() | Cùng signature |
jest.spyOn(obj, 'method') | vi.spyOn(obj, 'method') | Cùng signature |
jest.mock('./path') | vi.mock('./path') | Factory phải trả về object tường minh { default, ... } |
jest.requireActual('mod') | await vi.importActual('mod') | Phải await — trả về Promise |
jest.useFakeTimers() | vi.useFakeTimers() | Giống nhau |
jest.useRealTimers() | vi.useRealTimers() | Giống nhau |
jest.clearAllMocks() | vi.clearAllMocks() | Giống nhau |
jest.resetAllMocks() | vi.resetAllMocks() | Hành vi khác — xem phần Những điểm cần chú ý |
jest.restoreAllMocks() | vi.restoreAllMocks() | Giống nhau |
jest.setTimeout(5000) | vi.setConfig({ testTimeout: 5000 }) | API đã thay đổi |
JEST_WORKER_ID | VITEST_POOL_ID hoặc VITEST_WORKER_ID | Cả hai biến môi trường đều có sẵn |
Thay đổi cách import type:
// Jest
let fn: jest.Mock<(name: string) => number>
// Vitest
import type { Mock } from 'vitest'
let fn: Mock<(name: string) => number>
Bước 4: Xử lý config files
Babel: nếu bạn dùng Babel chỉ để xử lý TypeScript hoặc JSX trong tests, hãy gỡ nó ra:
npm uninstall @babel/core @babel/preset-env @babel/preset-typescript babel-jest
Vitest xử lý TypeScript và JSX qua esbuild. Nếu bạn dùng Babel để transform cho production build ngoài phạm vi test, hãy giữ nó trong app build config và chỉ bỏ khỏi test runner.
CSS và assets: Vitest xử lý CSS imports ngay từ đầu trong hầu hết cấu hình. Nếu gặp lỗi với CSS module imports, thêm css: true vào block test.
Migration moduleNameMapper: cấu hình path alias của Jest tương ứng với resolve.alias trong Vitest:
// Jest (jest.config.js)
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.css$': '<rootDir>/__mocks__/styleMock.js',
}
// Vitest (vitest.config.ts)
import path from 'path'
// Trong defineConfig:
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
// CSS được xử lý tự nhiên — không cần styleMock trong hầu hết trường hợp
Thư mục __mocks__: Jest tự động áp dụng các file trong __mocks__/. Vitest không làm vậy. Hãy chuyển logic auto-mock mà bạn đang dựa vào vào setupFiles, hoặc gọi vi.mock() tường minh trong từng test file cần dùng.
Bước 5: Chạy và khắc phục các lỗi còn lại
Chạy toàn bộ test suite và xem còn gì sót lại:
npm test
Các lỗi còn sót lại sau codemod rơi vào bốn nhóm.
Đường dẫn import @testing-library/jest-dom. Lỗi này phá vỡ mọi DOM assertion một cách lặng lẽ — toBeDisabled(), toBeInTheDocument(), tất cả đều bị. Sửa trong vitest.setup.ts:
// Sai (không còn hoạt động sau khi migrate)
import '@testing-library/jest-dom'
// Đúng cho Vitest (v6+)
import '@testing-library/jest-dom/vitest'
Tái tạo snapshots. Định dạng snapshot của Vitest tương thích với Jest nhưng header nội bộ khác nhau. Chạy một lần sau khi migrate:
vitest run --update
Vitest 4.0 cũng in nội dung shadow DOM vào snapshots theo mặc định. Nếu điều đó làm hỏng các snapshot hiện có, hãy thêm printShadowRoot: false vào tùy chọn snapshot.
Coverage provider không khớp. V8 (mặc định) nhanh hơn và gần như không tốn overhead runtime. Istanbul thêm 15–30% overhead nhưng cho kết quả giống với những gì bạn đã có trong Jest. Nếu CI của bạn có coverage gate, hãy so sánh output của cả hai trước khi chọn — ignoreEmptyLines được bật mặc định từ Vitest 3+, khiến tỷ lệ phần trăm thay đổi đôi chút.
Mock factory phức tạp. Codemod không xử lý đầy đủ các trường hợp factory trả về implicit default. Xem Gotcha #2 bên dưới.
Ví dụ thực tế — test React Button trước và sau:
// Button.test.tsx — TRƯỚC (Jest)
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import Button from './Button'
describe('Button', () => {
it('calls onClick when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('renders disabled state', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
})
// Button.test.tsx — SAU (Vitest, globals: true)
import React from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import Button from './Button'
describe('Button', () => {
it('calls onClick when clicked', () => {
const handleClick = vi.fn() // thay đổi duy nhất: jest.fn() → vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByText('Click me'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('renders disabled state', () => {
render(<Button disabled>Click me</Button>)
expect(screen.getByText('Click me')).toBeDisabled()
})
})
Với globals: true, describe, it, và expect không cần import. Thay đổi duy nhất trong file này là jest.fn() → vi.fn(), và codemod sẽ tự xử lý.
Những điểm cần chú ý
1. Hành vi mockReset() đã thay đổi. mockReset() của Jest thay thế implementation bằng () => undefined. mockReset() của Vitest khôi phục lại implementation gốc mà bạn truyền vào vi.fn(impl). Bất kỳ test nào tạo vi.fn(someImpl) rồi gọi mockReset() với kỳ vọng nhận về undefined sẽ nhận được function gốc thay vào đó. Codemod không bắt được điều này — kiểm tra thủ công các call site.
2. Mock factory của module phải là object tường minh. Jest cho phép factory trả về một giá trị primitive. Vitest yêu cầu object tường minh với named exports:
// Jest — trả về primitive hoạt động được
jest.mock('./api', () => 'mocked-value')
// Vitest — phải tường minh
vi.mock('./api', () => ({
default: 'mocked-value',
namedExport: vi.fn(),
}))
// Truy cập module thật bên trong factory:
vi.mock('./utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('./utils')>()
return {
...actual,
formatDate: vi.fn(() => '2026-01-01'),
}
})
3. vi.importActual() là async. Hàm này thay thế jest.requireActual(). Bỏ quên await sẽ trả về một Promise thay vì module — lỗi runtime không có thông báo lỗi hữu ích.
4. Đánh đổi giữa jsdom và happy-dom. Bắt đầu với jsdom — đây là thứ Jest đã dùng, test của bạn sẽ pass ngay mà không cần thay đổi gì. Sau khi toàn bộ suite đã xanh, chuyển sang happy-dom để tăng tốc DOM test 2–4×. Các vấn đề đã biết: một số edge case với MutationObserver, một vài Web API còn thiếu. Chạy --reporter=verbose và so sánh trước khi bật trong CI.
5. Ký tự phân tách tên test đã thay đổi. Jest dùng dấu cách: "describe test". Vitest dùng >: "describe > test". Bất kỳ CI filter nào truyền --testNamePattern theo định dạng cũ sẽ không khớp với test nào mà không báo lỗi. Cập nhật các grep pattern trước khi xóa Jest config.
6. Giá trị trả về của hook teardown. Vitest hiểu giá trị truthy trả về từ beforeEach là một teardown callback và gọi nó sau mỗi test:
// Hỏng trong Vitest — arrow function trả về implicit
beforeEach(() => setActivePinia(createTestingPinia()))
// An toàn — block body trả về undefined
beforeEach(() => { setActivePinia(createTestingPinia()) })
setActivePinia của Pinia trả về một object truthy, nên dạng arrow function đăng ký nó như một teardown. Triệu chứng là test order failures không liên tục và rất khó debug.
Kết luận
Nên migrate nếu: bạn đang dùng Vite, TypeScript-first, đang phải trả chi phí CI thực sự, hoặc đang khổ sở với watch mode chậm. Khả năng tương thích là thật, codemod xử lý phần lớn công việc, và bạn sẽ cảm nhận ngay sự khác biệt về hiệu năng ngay từ lần chạy đầu tiên.
Chờ thêm nếu: bạn đang dùng React Native (không được hỗ trợ), có một CJS monorepo lớn mà Jest đang ổn định và đủ nhanh, hoặc phụ thuộc vào các Jest plugin chưa có bản tương đương trong Vitest.
Khối lượng công việc migration cho 200–500 test là 1–3 ngày. Với 82 file như team trong case study, mất 2 tuần end-to-end — nhưng họ chỉ phải tắt 1 test, điều đó nói lên rất nhiều về mức độ tương thích.
Jest vẫn không biến mất. Meta vẫn đang duy trì nó, và nó vẫn là lựa chọn đúng đắn cho React Native và các codebase CJS cũ. Nhưng với bất kỳ dự án mới nào dùng Vite hoặc TypeScript-first vào năm 2026, việc chọn Jest cần có lý do cụ thể.
Nếu bạn cũng đang hiện đại hóa bundler, hãy xem hướng dẫn migrate Webpack sang Vite của chúng tôi — bài đó đề cập đến phần build-tool trong quá trình chuyển đổi TypeScript-first tương tự.