📖 核心概念
- 测试金字塔:单元→集成→E2E
- Vitest配置与API
- 测试模式:AAA(Arrange-Act-Assert)
- Mock、Spy与Stub
- 组件测试:Testing Library
- 测试覆盖率与CI集成
💻 代码实现
Vitest单元测试
✅ typescript
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/**/*.d.ts'],
thresholds: { branches: 80, functions: 80, lines: 80, statements: 80 },
},
},
});
// ===== 被测代码: src/utils/format.ts =====
export function formatCurrency(amount: number, currency = 'CNY'): string {
if (!Number.isFinite(amount)) throw new Error('Invalid amount');
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency,
minimumFractionDigits: 2,
}).format(amount);
}
export function debounce<T extends (...args: any[]) => any>(
fn: T,
ms: number
): (...args: Parameters<T>) => void {
let timer: ReturnType<typeof setTimeout>;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// ===== 测试: src/utils/format.test.ts =====
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { formatCurrency, debounce } from './format';
describe('formatCurrency', () => {
it('格式化人民币', () => {
expect(formatCurrency(1234.5)).toBe('¥1,234.50');
});
it('格式化美元', () => {
expect(formatCurrency(1234.5, 'USD')).toMatch(/1,234.50/);
});
it('处理零', () => {
expect(formatCurrency(0)).toBe('¥0.00');
});
it('处理负数', () => {
expect(formatCurrency(-100)).toContain('-');
});
it('非法输入抛出错误', () => {
expect(() => formatCurrency(NaN)).toThrow('Invalid amount');
expect(() => formatCurrency(Infinity)).toThrow('Invalid amount');
});
});
describe('debounce', () => {
beforeEach(() => { vi.useFakeTimers(); });
it('延迟执行函数', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced('a');
debounced('b');
debounced('c');
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledOnce();
expect(fn).toHaveBeenCalledWith('c');
});
});
组件测试
✅ typescript
// ===== React组件测试 =====
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { SearchForm } from './SearchForm';
describe('SearchForm', () => {
it('渲染搜索框和按钮', () => {
render(<SearchForm onSearch={vi.fn()} />);
expect(screen.getByPlaceholderText('搜索...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: '搜索' })).toBeInTheDocument();
});
it('输入文字并提交', async () => {
const onSearch = vi.fn();
render(<SearchForm onSearch={onSearch} />);
const input = screen.getByPlaceholderText('搜索...');
await userEvent.type(input, 'React');
await userEvent.click(screen.getByRole('button', { name: '搜索' }));
expect(onSearch).toHaveBeenCalledWith('React');
});
it('空输入不触发搜索', async () => {
const onSearch = vi.fn();
render(<SearchForm onSearch={onSearch} />);
await userEvent.click(screen.getByRole('button', { name: '搜索' }));
expect(onSearch).not.toHaveBeenCalled();
});
});
// ===== 自定义Hook测试 =====
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
describe('useCounter', () => {
it('初始值为0', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
});
it('increment增加计数', () => {
const { result } = renderHook(() => useCounter());
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
it('支持自定义初始值', () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
});
// ===== API Mock测试 =====
import { server } from './test/server'; // MSW
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('fetchUsers', () => {
it('成功获取用户列表', async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
expect(users[0].name).toBe('张三');
});
it('处理服务器错误', async () => {
server.use(
http.get('/api/users', () => HttpResponse.json(
{ message: 'Internal Error' }, { status: 500 }
))
);
await expect(fetchUsers()).rejects.toThrow('HTTP 500');
});
})
🎯 练习任务
- 1 为工具函数库编写完整的单元测试,覆盖率>90%
- 2 测试一个React组件的交互行为
- 3 使用MSW mock API,编写集成测试
- 4 配置GitHub Actions运行测试并生成覆盖率报告
🏆 成就解锁
测试工匠 — 掌握前端测试体系,用测试保障代码质量