Vitest 测试实战:现代前端单元测试的最佳实践

3338 字
17 分钟
Vitest 测试实战:现代前端单元测试的最佳实践

前言#

测试是前端工程化的基石,但长期以来,前端测试体验并不好——Jest 配置繁琐、转换慢、ESM 支持差。Vitest 的出现彻底改变了这个局面。它基于 Vite 构建,天然支持 ESM、TypeScript、JSX,零配置即可开箱使用,而且速度快得惊人。

本文将深入 Vitest 的实战使用,涵盖与 Jest 的对比、快照测试、组件测试、Mock 策略、覆盖率分析和 CI 集成,帮助你建立一套现代化的前端测试体系。

一、Vitest vs Jest:为什么要迁移?#

1.1 核心差异对比#

特性JestVitest
模块系统CJS 为主,ESM 实验性原生 ESM
TypeScript需要 ts-jest/babel原生支持(esbuild)
配置共享独立配置复用 vite.config.ts
热更新Watch 模式极快
执行速度中等极快(Vite 的按需编译)
兼容性Jest API兼容 Jest API

1.2 速度差异的本质原因#

Jest 的慢在于它的转换管道:每个文件都需要通过 Babel/ts-jest 转换,然后在 Node.js 的 CJS 环境中执行。而 Vitest 直接复用 Vite 的开发服务器能力——按需编译、原生 ESM、esbuild 转换

Jest 执行流程:
源文件 → Babel/ts-jest 转换 → CJS 模块 → Node.js 执行
(每个文件都要完整转换)
Vitest 执行流程:
源文件 → esbuild 快速转换 → ESM 模块 → Vite 的模块图按需加载
(只转换用到的模块,增量更新)

1.3 快速上手#

Terminal window
# 安装
npm install -D vitest @vitest/coverage-v8
# 如果需要 UI 界面
npm install -D @vitest/ui
// vite.config.ts - Vitest 配置直接写在 Vite 配置中
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
test: {
globals: true, // 全局注入 describe/it/expect
environment: 'jsdom', // DOM 环境
include: ['src/**/*.{test,spec}.{ts,js}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.d.ts', 'src/**/*.test.ts'],
},
// 测试超时
testTimeout: 10000,
// 开启多线程
pool: 'threads',
},
});
package.json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui"
}
}

二、测试编写基础#

2.1 基础断言#

Vitest 的断言 API 与 Jest 完全兼容,同时还扩展了更多能力:

src/utils/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('Division by zero');
return a / b;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
export function isEven(n: number): boolean {
return n % 2 === 0;
}
src/utils/math.test.ts
import { describe, it, expect } from 'vitest';
import { add, divide, clamp, isEven } from './math';
describe('math utils', () => {
describe('add', () => {
it('should add two positive numbers', () => {
expect(add(1, 2)).toBe(3);
});
it('should handle negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
expect(add(-1, 2)).toBe(1);
});
it('should handle floating point', () => {
// 注意浮点精度
expect(add(0.1, 0.2)).toBeCloseTo(0.3, 10);
});
});
describe('divide', () => {
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('should throw on division by zero', () => {
expect(() => divide(10, 0)).toThrowError('Division by zero');
});
// 使用 each 进行参数化测试
it.each([
[10, 2, 5],
[9, 3, 3],
[100, 10, 10],
[7, 2, 3.5],
])('divide(%i, %i) = %f', (a, b, expected) => {
expect(divide(a, b)).toBe(expected);
});
});
describe('clamp', () => {
it('should return value when within range', () => {
expect(clamp(5, 0, 10)).toBe(5);
});
it('should clamp to min', () => {
expect(clamp(-5, 0, 10)).toBe(0);
});
it('should clamp to max', () => {
expect(clamp(15, 0, 10)).toBe(10);
});
});
});

2.2 异步测试#

src/api/user.ts
export interface User {
id: number;
name: string;
email: string;
}
export async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`User not found: ${id}`);
}
return response.json();
}
export function fetchUserWithCallback(
id: number,
callback: (err: Error | null, user?: User) => void
): void {
fetch(`/api/users/${id}`)
.then(res => {
if (!res.ok) throw new Error(`User not found: ${id}`);
return res.json();
})
.then(user => callback(null, user))
.catch(err => callback(err));
}
src/api/user.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fetchUser } from './user';
// Mock fetch
const mockFetch = vi.fn();
vi.stubGlobal('fetch', mockFetch);
describe('fetchUser', () => {
beforeEach(() => {
mockFetch.mockReset();
});
it('should fetch user successfully', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' };
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(mockUser),
});
const user = await fetchUser(1);
expect(user).toEqual(mockUser);
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
});
it('should throw on non-ok response', async () => {
mockFetch.mockResolvedValueOnce({ ok: false, status: 404 });
await expect(fetchUser(999)).rejects.toThrowError('User not found: 999');
});
it('should handle network errors', async () => {
mockFetch.mockRejectedValueOnce(new TypeError('Network error'));
await expect(fetchUser(1)).rejects.toThrowError('Network error');
});
});

三、快照测试#

3.1 基础快照#

快照测试非常适合验证输出结构不会意外改变:

src/utils/formatter.ts
export interface FormattedOutput {
title: string;
body: string;
meta: Record<string, string>;
}
export function formatArticle(raw: string, author: string): FormattedOutput {
const lines = raw.trim().split('\n');
const title = lines[0].replace(/^#\s*/, '');
const body = lines.slice(1).join('\n').trim();
return {
title,
body,
meta: {
author,
wordCount: String(body.length),
createdAt: new Date().toISOString().split('T')[0],
},
};
}
src/utils/formatter.test.ts
import { describe, it, expect, vi } from 'vitest';
import { formatArticle } from './formatter';
describe('formatArticle', () => {
// 冻结时间以确保快照稳定
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-05'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should format article correctly', () => {
const raw = `# Hello World
This is the body of the article.
It has multiple lines.`;
// 内联快照 - 直接写在测试文件中
expect(formatArticle(raw, 'Alice')).toMatchInlineSnapshot(`
{
"body": "This is the body of the article.
It has multiple lines.",
"meta": {
"author": "Alice",
"createdAt": "2026-04-05",
"wordCount": "50",
},
"title": "Hello World",
}
`);
});
it('should match file snapshot', () => {
const raw = `# Test
Body content`;
// 文件快照 - 保存到 __snapshots__ 目录
expect(formatArticle(raw, 'Bob')).toMatchSnapshot();
});
});

3.2 自定义序列化器#

vitest.setup.ts
import { expect } from 'vitest';
// 自定义快照序列化器 - 隐藏动态字段
expect.addSnapshotSerializer({
serialize(val: any, config, indentation, depth, refs, printer) {
// 将日期字符串替换为占位符
const sanitized = JSON.parse(JSON.stringify(val, (key, value) => {
if (key === 'createdAt' || key === 'updatedAt') {
return '[DATE]';
}
if (key === 'id' && typeof value === 'string' && value.length > 20) {
return '[UUID]';
}
return value;
}));
return printer(sanitized, config, indentation, depth, refs);
},
test(val) {
return val && typeof val === 'object' && ('createdAt' in val || 'id' in val);
},
});

四、Mock 策略深入#

4.1 模块 Mock#

src/services/notification.ts
import { sendEmail } from './email';
import { sendSMS } from './sms';
import { logger } from './logger';
export async function notifyUser(
userId: string,
message: string,
channels: ('email' | 'sms')[]
): Promise<{ success: boolean; channels: string[] }> {
const sent: string[] = [];
for (const channel of channels) {
try {
if (channel === 'email') {
await sendEmail(userId, message);
sent.push('email');
} else if (channel === 'sms') {
await sendSMS(userId, message);
sent.push('sms');
}
} catch (err) {
logger.error(`Failed to send ${channel} to ${userId}`, err);
}
}
return { success: sent.length > 0, channels: sent };
}
src/services/notification.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { notifyUser } from './notification';
// Mock 整个模块
vi.mock('./email', () => ({
sendEmail: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./sms', () => ({
sendSMS: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('./logger', () => ({
logger: {
error: vi.fn(),
info: vi.fn(),
},
}));
// 导入被 mock 的模块以进行断言
import { sendEmail } from './email';
import { sendSMS } from './sms';
import { logger } from './logger';
describe('notifyUser', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should send email notification', async () => {
const result = await notifyUser('user-1', 'Hello!', ['email']);
expect(result).toEqual({ success: true, channels: ['email'] });
expect(sendEmail).toHaveBeenCalledWith('user-1', 'Hello!');
expect(sendSMS).not.toHaveBeenCalled();
});
it('should send both email and sms', async () => {
const result = await notifyUser('user-1', 'Hello!', ['email', 'sms']);
expect(result).toEqual({ success: true, channels: ['email', 'sms'] });
expect(sendEmail).toHaveBeenCalledOnce();
expect(sendSMS).toHaveBeenCalledOnce();
});
it('should handle partial failure gracefully', async () => {
// email 失败,sms 成功
vi.mocked(sendEmail).mockRejectedValueOnce(new Error('SMTP error'));
const result = await notifyUser('user-1', 'Hello!', ['email', 'sms']);
expect(result).toEqual({ success: true, channels: ['sms'] });
expect(logger.error).toHaveBeenCalledWith(
'Failed to send email to user-1',
expect.any(Error)
);
});
it('should return failure when all channels fail', async () => {
vi.mocked(sendEmail).mockRejectedValueOnce(new Error('SMTP error'));
vi.mocked(sendSMS).mockRejectedValueOnce(new Error('SMS quota exceeded'));
const result = await notifyUser('user-1', 'Hello!', ['email', 'sms']);
expect(result).toEqual({ success: false, channels: [] });
expect(logger.error).toHaveBeenCalledTimes(2);
});
});

4.2 Spy 与部分 Mock#

src/utils/storage.ts
export class StorageService {
private prefix: string;
constructor(prefix = 'app') {
this.prefix = prefix;
}
private getKey(key: string): string {
return `${this.prefix}:${key}`;
}
set(key: string, value: unknown): void {
const serialized = JSON.stringify(value);
localStorage.setItem(this.getKey(key), serialized);
}
get<T>(key: string): T | null {
const raw = localStorage.getItem(this.getKey(key));
if (raw === null) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
remove(key: string): void {
localStorage.removeItem(this.getKey(key));
}
clear(): void {
const keys = Object.keys(localStorage);
for (const key of keys) {
if (key.startsWith(`${this.prefix}:`)) {
localStorage.removeItem(key);
}
}
}
}
src/utils/storage.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { StorageService } from './storage';
describe('StorageService', () => {
let storage: StorageService;
beforeEach(() => {
// 使用 jsdom 的 localStorage
localStorage.clear();
storage = new StorageService('test');
});
it('should set and get values', () => {
storage.set('user', { name: 'Alice', age: 30 });
const user = storage.get<{ name: string; age: number }>('user');
expect(user).toEqual({ name: 'Alice', age: 30 });
});
it('should return null for missing keys', () => {
expect(storage.get('nonexistent')).toBeNull();
});
it('should handle invalid JSON gracefully', () => {
// 直接写入非法 JSON
localStorage.setItem('test:broken', '{invalid json}');
expect(storage.get('broken')).toBeNull();
});
it('should use spy to verify localStorage calls', () => {
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem');
storage.set('key', 'value');
expect(setItemSpy).toHaveBeenCalledWith('test:key', '"value"');
setItemSpy.mockRestore();
});
it('should clear only prefixed keys', () => {
storage.set('a', 1);
storage.set('b', 2);
localStorage.setItem('other:c', '3'); // 不同前缀
storage.clear();
expect(storage.get('a')).toBeNull();
expect(storage.get('b')).toBeNull();
expect(localStorage.getItem('other:c')).toBe('3');
});
});

4.3 Timer Mock#

src/utils/debounce.ts
export function debounce<T extends (...args: any[]) => any>(
fn: T,
delay: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout> | null = null;
return (...args: Parameters<T>) => {
if (timer !== null) {
clearTimeout(timer);
}
timer = setTimeout(() => {
fn(...args);
timer = null;
}, delay);
};
}
src/utils/debounce.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './debounce';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should delay function execution', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(299);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(fn).toHaveBeenCalledOnce();
});
it('should reset timer on subsequent calls', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // 重置计时器
vi.advanceTimersByTime(200);
expect(fn).not.toHaveBeenCalled(); // 还没到 300ms
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledOnce();
});
it('should pass arguments correctly', () => {
const fn = vi.fn();
const debounced = debounce(fn, 100);
debounced('hello', 42);
vi.runAllTimers();
expect(fn).toHaveBeenCalledWith('hello', 42);
});
});

五、Vue 组件测试#

5.1 环境搭建#

Terminal window
npm install -D @vue/test-utils @vitejs/plugin-vue
src/components/Counter.vue
<script setup lang="ts">
import { ref, computed } from 'vue';
const props = withDefaults(defineProps<{
initial?: number;
min?: number;
max?: number;
}>(), {
initial: 0,
min: -Infinity,
max: Infinity,
});
const emit = defineEmits<{
change: [value: number];
}>();
const count = ref(props.initial);
const canDecrement = computed(() => count.value > props.min);
const canIncrement = computed(() => count.value < props.max);
function increment() {
if (canIncrement.value) {
count.value++;
emit('change', count.value);
}
}
function decrement() {
if (canDecrement.value) {
count.value--;
emit('change', count.value);
}
}
</script>
<template>
<div class="counter">
<button
data-testid="decrement"
:disabled="!canDecrement"
@click="decrement"
>
-
</button>
<span data-testid="count">{{ count }}</span>
<button
data-testid="increment"
:disabled="!canIncrement"
@click="increment"
>
+
</button>
</div>
</template>
src/components/Counter.test.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import Counter from './Counter.vue';
describe('Counter', () => {
it('should render with default initial value', () => {
const wrapper = mount(Counter);
expect(wrapper.get('[data-testid="count"]').text()).toBe('0');
});
it('should render with custom initial value', () => {
const wrapper = mount(Counter, {
props: { initial: 10 },
});
expect(wrapper.get('[data-testid="count"]').text()).toBe('10');
});
it('should increment on click', async () => {
const wrapper = mount(Counter);
await wrapper.get('[data-testid="increment"]').trigger('click');
expect(wrapper.get('[data-testid="count"]').text()).toBe('1');
});
it('should decrement on click', async () => {
const wrapper = mount(Counter, { props: { initial: 5 } });
await wrapper.get('[data-testid="decrement"]').trigger('click');
expect(wrapper.get('[data-testid="count"]').text()).toBe('4');
});
it('should emit change event', async () => {
const wrapper = mount(Counter);
await wrapper.get('[data-testid="increment"]').trigger('click');
expect(wrapper.emitted('change')).toHaveLength(1);
expect(wrapper.emitted('change')![0]).toEqual([1]);
});
it('should respect max limit', async () => {
const wrapper = mount(Counter, {
props: { initial: 2, max: 3 },
});
await wrapper.get('[data-testid="increment"]').trigger('click'); // 3
await wrapper.get('[data-testid="increment"]').trigger('click'); // still 3
expect(wrapper.get('[data-testid="count"]').text()).toBe('3');
expect(
wrapper.get('[data-testid="increment"]').attributes('disabled')
).toBeDefined();
});
it('should respect min limit', async () => {
const wrapper = mount(Counter, {
props: { initial: 1, min: 0 },
});
await wrapper.get('[data-testid="decrement"]').trigger('click'); // 0
await wrapper.get('[data-testid="decrement"]').trigger('click'); // still 0
expect(wrapper.get('[data-testid="count"]').text()).toBe('0');
expect(
wrapper.get('[data-testid="decrement"]').attributes('disabled')
).toBeDefined();
});
});

5.2 测试含有 Pinia Store 的组件#

src/stores/todo.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export interface Todo {
id: number;
text: string;
done: boolean;
}
export const useTodoStore = defineStore('todo', () => {
const todos = ref<Todo[]>([]);
let nextId = 1;
const remaining = computed(() => todos.value.filter(t => !t.done).length);
function addTodo(text: string) {
todos.value.push({ id: nextId++, text, done: false });
}
function toggleTodo(id: number) {
const todo = todos.value.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}
function removeTodo(id: number) {
todos.value = todos.value.filter(t => t.id !== id);
}
return { todos, remaining, addTodo, toggleTodo, removeTodo };
});
src/stores/todo.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { useTodoStore } from './todo';
describe('Todo Store', () => {
beforeEach(() => {
// 每个测试创建新的 pinia 实例,确保隔离
setActivePinia(createPinia());
});
it('should start empty', () => {
const store = useTodoStore();
expect(store.todos).toHaveLength(0);
expect(store.remaining).toBe(0);
});
it('should add todo', () => {
const store = useTodoStore();
store.addTodo('Buy milk');
expect(store.todos).toHaveLength(1);
expect(store.todos[0]).toMatchObject({ text: 'Buy milk', done: false });
expect(store.remaining).toBe(1);
});
it('should toggle todo', () => {
const store = useTodoStore();
store.addTodo('Buy milk');
store.toggleTodo(store.todos[0].id);
expect(store.todos[0].done).toBe(true);
expect(store.remaining).toBe(0);
store.toggleTodo(store.todos[0].id);
expect(store.todos[0].done).toBe(false);
expect(store.remaining).toBe(1);
});
it('should remove todo', () => {
const store = useTodoStore();
store.addTodo('A');
store.addTodo('B');
const idToRemove = store.todos[0].id;
store.removeTodo(idToRemove);
expect(store.todos).toHaveLength(1);
expect(store.todos[0].text).toBe('B');
});
});

六、覆盖率分析#

6.1 配置覆盖率#

vite.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'json-summary', 'html', 'lcov'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.test.ts',
'src/**/*.spec.ts',
'src/main.ts',
'src/App.vue',
],
// 覆盖率阈值 - 低于这些值 CI 会失败
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
// 是否统计未被测试文件导入的源文件
all: true,
},
},
});

6.2 覆盖率报告解读#

运行 vitest run --coverage 后会输出类似:

% Coverage report from v8
------------------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
------------------------------|---------|----------|---------|---------|
All files | 92.31 | 87.50 | 90.00 | 92.31 |
src/utils/math.ts | 100.00 | 100.00 | 100.00 | 100.00 |
src/utils/debounce.ts | 100.00 | 100.00 | 100.00 | 100.00 |
src/utils/storage.ts | 85.71 | 75.00 | 80.00 | 85.71 |
src/services/notification.ts | 88.89 | 83.33 | 100.00 | 88.89 |
------------------------------|---------|----------|---------|---------|

关键指标:

  • Statements(语句):每条语句是否被执行
  • Branches(分支):if/else、switch 等分支是否都走到
  • Functions(函数):每个函数是否被调用
  • Lines(行):每一行是否被执行

七、CI 集成#

7.1 GitHub Actions 配置#

.github/workflows/test.yml
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npx vitest run --coverage --reporter=json --outputFile=test-results.json
- name: Upload coverage to Codecov
if: matrix.node-version == 20
uses: codecov/codecov-action@v4
with:
files: ./coverage/lcov.info
fail_ci_if_error: true
- name: Check coverage thresholds
run: npx vitest run --coverage --coverage.thresholds.100=false

7.2 预提交钩子#

Terminal window
npm install -D husky lint-staged
package.json
{
"lint-staged": {
"src/**/*.{ts,vue}": [
"vitest related --run"
]
}
}
.husky/pre-commit
npx lint-staged

vitest related --run 会自动找出与修改文件相关的测试并执行,避免每次提交都跑全量测试。

八、高级技巧#

8.1 测试工厂函数#

当多个测试需要相似的数据结构时,使用工厂函数避免重复:

tests/factories/user.ts
import type { User } from '@/types';
let idCounter = 0;
export function createUser(overrides: Partial<User> = {}): User {
return {
id: ++idCounter,
name: `User ${idCounter}`,
email: `user${idCounter}@example.com`,
role: 'member',
createdAt: new Date().toISOString(),
...overrides,
};
}
// 使用
it('should format admin user differently', () => {
const admin = createUser({ role: 'admin', name: 'Admin Alice' });
expect(formatUserBadge(admin)).toContain('⭐');
});

8.2 并发测试#

// 使用 concurrent 标记可并发执行的测试
describe.concurrent('independent tests', () => {
it('test 1', async () => {
await new Promise(r => setTimeout(r, 100));
expect(1 + 1).toBe(2);
});
it('test 2', async () => {
await new Promise(r => setTimeout(r, 100));
expect(2 + 2).toBe(4);
});
// 这两个测试会并行执行,总耗时约 100ms 而非 200ms
});

8.3 类型测试#

Vitest 支持直接测试 TypeScript 类型:

src/types.test-d.ts
import { describe, it, expectTypeOf } from 'vitest';
import { add, clamp } from './utils/math';
describe('type tests', () => {
it('add should accept numbers and return number', () => {
expectTypeOf(add).toBeFunction();
expectTypeOf(add).parameter(0).toBeNumber();
expectTypeOf(add).returns.toBeNumber();
});
it('clamp should require three number params', () => {
expectTypeOf(clamp).parameters.toEqualTypeOf<[number, number, number]>();
});
});

运行类型测试:vitest typecheck

总结#

Vitest 不仅仅是”更快的 Jest”——它代表了前端测试的现代化方向:

  1. 零配置:与 Vite 深度集成,无需额外的转换器和配置
  2. 原生 ESM:告别 CJS 的兼容性噩梦
  3. 极致速度:Watch 模式下几乎即时反馈
  4. 完整生态:覆盖率、UI、类型测试一应俱全
  5. Jest 兼容:迁移成本极低

如果你的项目已经在使用 Vite,迁移到 Vitest 几乎是无脑的选择。即使不用 Vite,Vitest 独立运行的体验也远超 Jest。写测试应该是愉快的,而不是负担——Vitest 做到了。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Vitest 测试实战:现代前端单元测试的最佳实践
https://boke.hackerdream.xyz/posts/vitest-testing-patterns/
作者
晴天
发布于
2026-02-27
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
晴天
Hello, I'm 晴天.
公告
欢迎来到我的博客!这是一则示例公告。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
125
分类
17
标签
287
总字数
257,955
运行时长
0
最后活动
0 天前

目录