正在加载,请稍候…

JavaScript 测试指南:Jest vs Vitest、单元测试与集成测试

2026 年 JavaScript 测试完整指南。对比 Jest 和 Vitest,学习单元测试模式、模拟策略、React 组件测试及 CI/CD 集成。

JavaScript 测试指南:Jest vs Vitest、单元测试与集成测试

JavaScript 测试指南:Jest vs Vitest、单元测试与集成测试

编写测试是一项投资,会带来回报。本指南涵盖从搭建测试基础设施到编写能捕获真实 bug 的有效测试的所有内容。

Jest vs Vitest:如何选择?

特性 Jest Vitest
启动速度 ~2-5s ~200ms
监视模式 良好 优秀
配置需求 中等 极少
Vite 项目 复杂设置 原生支持
TypeScript 通过 Babel/ts-jest 原生支持
快照测试
覆盖率
生态系统 庞大 快速增长

经验法则:Vite 项目 → Vitest。Create React App / 遗留项目 → Jest。

JavaScript 测试指南:Jest vs Vitest、单元测试与集成测试 插图

设置 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();
  });
});

JavaScript 测试指南:Jest vs Vitest、单元测试与集成测试 插图

模拟(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();
  });
});

JavaScript 测试指南:Jest vs Vitest、单元测试与集成测试 插图

使用 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 衡量不同实现的性能。