📚 现代前端开发 目录
阶段2:工程化
第 14 / 35 课

单元测试:Vitest

测试驱动开发的力量

📖 核心概念

💻 代码实现

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');
  });
})

🎯 练习任务

🏆 成就解锁
测试工匠 — 掌握前端测试体系,用测试保障代码质量