JavaScript 测试指南:Jest vs Vitest、单元测试与集成测试
编写测试是一项投资,会带来回报。本指南涵盖从搭建测试基础设施到编写能捕获真实 bug 的有效测试的所有内容。
Jest vs Vitest:如何选择?
| 特性 | Jest | Vitest |
|---|---|---|
| 启动速度 | ~2-5s | ~200ms |
| 监视模式 | 良好 | 优秀 |
| 配置需求 | 中等 | 极少 |
| Vite 项目 | 复杂设置 | 原生支持 |
| TypeScript | 通过 Babel/ts-jest | 原生支持 |
| 快照测试 | ✅ | ✅ |
| 覆盖率 | ✅ | ✅ |
| 生态系统 | 庞大 | 快速增长 |
经验法则:Vite 项目 → Vitest。Create React App / 遗留项目 → Jest。
设置 Vitest
npm install --save-dev vitest @vitest/coverage-v8 happy-dom
// vite.config.ts (或 vitest.config.ts)
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'happy-dom', // 类似 jsdom 的浏览器环境
globals: true, // 无需导入 describe/it/expect
setupFiles: './src/test/setup.ts',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
},
},
});
// src/test/setup.ts
import '@testing-library/jest-dom'; // 添加 .toBeInTheDocument(), .toHaveClass() 等
import { cleanup } from '@testing-library/react';
import { afterEach } from 'vitest';
// 每个测试后清理
afterEach(() => {
cleanup();
});
设置 Jest
npm install --save-dev jest @types/jest ts-jest @testing-library/react @testing-library/jest-dom jest-environment-jsdom
// jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jest-environment-jsdom',
setupFilesAfterFramework: ['@testing-library/jest-dom'],
moduleNameMapper: {
'^@/(.*)#39;: '<rootDir>/src/$1', // 路径别名
'\.(css|scss)#39;: 'identity-obj-proxy', // CSS 模块模拟
'\.(jpg|png|svg)#39;: '<rootDir>/src/__mocks__/fileMock.js',
},
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.test.{ts,tsx}'],
};
单元测试模式
测试纯函数
// utils/formatters.ts
export function formatCurrency(amount: number, currency = 'USD'): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount);
}
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
export function parseJWT(token: string): { header: object; payload: object } | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
return {
header: JSON.parse(atob(parts[0])),
payload: JSON.parse(atob(parts[1])),
};
} catch {
return null;
}
}
// utils/formatters.test.ts
import { describe, it, expect } from 'vitest';
import { formatCurrency, truncateText, parseJWT } from './formatters';
describe('formatCurrency', () => {
it('正确格式化美元金额', () => {
expect(formatCurrency(1234.56)).toBe('$1,234.56');
expect(formatCurrency(0)).toBe('$0.00');
expect(formatCurrency(1000000)).toBe('$1,000,000.00');
});
it('支持不同货币', () => {
expect(formatCurrency(100, 'EUR')).toBe('€100.00');
expect(formatCurrency(100, 'GBP')).toBe('£100.00');
});
});
describe('truncateText', () => {
it('文本在限制内时原样返回', () => {
expect(truncateText('Hello', 10)).toBe('Hello');
expect(truncateText('Hello', 5)).toBe('Hello');
});
it('截断长文本并添加省略号', () => {
expect(truncateText('Hello World', 8)).toBe('Hello...');
expect(truncateText('JavaScript is awesome', 15)).toBe('JavaScript is...');
});
});
describe('parseJWT', () => {
const validToken = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature';
it('解析有效的 JWT 结构', () => {
const result = parseJWT(validToken);
expect(result).not.toBeNull();
expect(result?.payload).toEqual({ sub: '123' });
});
it('对无效 token 返回 null', () => {
expect(parseJWT('not-a-jwt')).toBeNull();
expect(parseJWT('')).toBeNull();
expect(parseJWT('only.two')).toBeNull();
});
});
模拟(Mocking)
模拟模块
// api/userApi.ts
export async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// services/userService.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getUserProfile } from './userService';
import * as userApi from '../api/userApi';
// 模拟整个模块
vi.mock('../api/userApi');
describe('getUserProfile', () => {
beforeEach(() => {
vi.clearAllMocks(); // 在测试之间重置模拟
});
it('返回格式化后的用户资料', async () => {
// 设置模拟返回值
vi.mocked(userApi.fetchUser).mockResolvedValueOnce({
id: '123',
first_name: 'John',
last_name: 'Doe',
email: 'john@example.com',
});
const profile = await getUserProfile('123');
expect(profile).toEqual({
id: '123',
fullName: 'John Doe',
email: 'john@example.com',
});
expect(userApi.fetchUser).toHaveBeenCalledWith('123');
expect(userApi.fetchUser).toHaveBeenCalledTimes(1);
});
it('处理 API 错误', async () => {
vi.mocked(userApi.fetchUser).mockRejectedValueOnce(new Error('Network error'));
await expect(getUserProfile('123')).rejects.toThrow('Failed to fetch user profile');
});
});
模拟 fetch
import { describe, it, expect, vi, beforeEach } from 'vitest';
describe('API 调用', () => {
beforeEach(() => {
// 模拟全局 fetch
global.fetch = vi.fn();
});
it('调用正确的端点', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({ id: '1', name: 'Alice' }),
} as Response);
const user = await fetchUser('1');
expect(fetch).toHaveBeenCalledWith('/api/users/1');
expect(user.name).toBe('Alice');
});
it('在 HTTP 错误时抛出异常', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: false,
status: 404,
} as Response);
await expect(fetchUser('999')).rejects.toThrow('HTTP 404');
});
});
React 组件测试
npm install --save-dev @testing-library/react @testing-library/user-event
// components/LoginForm.tsx
export function LoginForm({ onSuccess }: { onSuccess: (user: User) => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const user = await login(email, password);
onSuccess(user);
} catch (err) {
setError('Invalid email or password');
} finally {
setLoading(false);
}
}
return (
<form onSubmit={handleSubmit}>
<input type="email" value={email} onChange={e => setEmail(e.target.value)}
placeholder="Email" required />
<input type="password" value={password} onChange={e => setPassword(e.target.value)}
placeholder="Password" required />
{error && <p role="alert">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? 'Logging in...' : 'Log In'}
</button>
</form>
);
}
// components/LoginForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { LoginForm } from './LoginForm';
import * as authApi from '../api/auth';
vi.mock('../api/auth');
describe('LoginForm', () => {
const user = userEvent.setup();
const mockOnSuccess = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('渲染表单元素', () => {
render(<LoginForm onSuccess={mockOnSuccess} />);
expect(screen.getByPlaceholderText('Email')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Password')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Log In' })).toBeInTheDocument();
});
it('使用用户凭据提交', async () => {
const mockUser = { id: '1', name: 'Alice', email: 'alice@example.com' };
vi.mocked(authApi.login).mockResolvedValueOnce(mockUser);
render(<LoginForm onSuccess={mockOnSuccess} />);
await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Log In' }));
await waitFor(() => {
expect(authApi.login).toHaveBeenCalledWith('alice@example.com', 'password123');
expect(mockOnSuccess).toHaveBeenCalledWith(mockUser);
});
});
it('登录失败时显示错误信息', async () => {
vi.mocked(authApi.login).mockRejectedValueOnce(new Error('Unauthorized'));
render(<LoginForm onSuccess={mockOnSuccess} />);
await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'wrongpassword');
await user.click(screen.getByRole('button', { name: 'Log In' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Invalid email or password');
expect(mockOnSuccess).not.toHaveBeenCalled();
});
it('加载时禁用按钮', async () => {
vi.mocked(authApi.login).mockImplementationOnce(
() => new Promise(resolve => setTimeout(resolve, 1000))
);
render(<LoginForm onSuccess={mockOnSuccess} />);
await user.type(screen.getByPlaceholderText('Email'), 'alice@example.com');
await user.type(screen.getByPlaceholderText('Password'), 'password123');
await user.click(screen.getByRole('button', { name: 'Log In' }));
expect(screen.getByRole('button', { name: 'Logging in...' })).toBeDisabled();
});
});
使用 MSW 进行集成测试
npm install --save-dev msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Alice Johnson',
email: 'alice@example.com',
});
}),
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json();
if (body.email === 'alice@example.com' && body.password === 'correct') {
return HttpResponse.json({ token: 'jwt-token-here' });
}
return new HttpResponse('Unauthorized', { status: 401 });
}),
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// src/test/setup.ts
import { server } from '../mocks/server';
import { beforeAll, afterAll, afterEach } from 'vitest';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
覆盖率目标
# 运行测试并生成覆盖率
npx vitest run --coverage
npx jest --coverage
# 在配置中设置覆盖率阈值
// vitest.config.ts
test: {
coverage: {
thresholds: {
branches: 70,
functions: 80,
lines: 80,
statements: 80,
}
}
}
覆盖率目标:
- 80%+ 的行/函数覆盖率是合理目标
- 100% 覆盖率 ≠ 无 bug 代码
- 关注关键路径:认证、支付、数据变更
在 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: '20'
cache: 'npm'
- run: npm ci
- name: Run tests
run: npm test -- --coverage --reporter=verbose
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
总结
| 测试类型 | 工具 | 何时编写 |
|---|---|---|
| 单元测试 | Vitest/Jest | 纯函数、工具函数、hooks |
| 组件测试 | RTL + Vitest | 带有交互的 UI 组件 |
| 集成测试 | MSW + RTL | 功能流程、API 交互 |
| 端到端测试 | Playwright/Cypress | 关键用户旅程 |
→ 使用 Benchmark Builder 衡量不同实现的性能。