前端单元测试从入门到精通:Jest与Testing Library实战

引言

在现代前端开发中,单元测试已经成为保证代码质量和项目稳定性的重要手段。本文将带你从零开始,深入学习如何使用Jest和Testing Library进行前端单元测试,涵盖从基础概念到高级实践的完整知识体系。

一、为什么需要单元测试?

1.1 单元测试的价值

  • 提高代码质量:通过测试驱动开发(TDD),促使我们编写更模块化、可维护的代码
  • 快速定位问题:当测试失败时,能够迅速定位到具体的问题代码
  • 重构信心:有了完善的测试覆盖,重构代码时可以放心大胆地进行
  • 文档作用:测试用例本身就是最好的代码使用文档
  • 降低维护成本:虽然前期需要投入时间编写测试,但长期来看可以大幅降低维护成本

1.2 常见的测试误区

  • 认为测试会拖慢开发速度
  • 只测试简单的函数,忽略复杂的业务逻辑
  • 过度追求测试覆盖率而忽视测试质量
  • 编写脆弱的测试(测试实现细节而非行为)

二、Jest基础入门

2.1 什么是Jest?

Jest是Facebook开源的JavaScript测试框架,具有以下特点:

  • 零配置:开箱即用
  • 快照测试:轻松测试UI组件
  • 并行测试:充分利用多核CPU
  • 丰富的API:提供完整的断言库和Mock功能
  • 优秀的错误提示:清晰的测试失败信息

2.2 安装与配置

复制代码
# 安装Jest
npm install --save-dev jest

# 对于TypeScript项目
npm install --save-dev jest @types/jest ts-jest

# 初始化配置
npx jest --init

基础的jest.config.js配置:

复制代码
module.exports = {
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
  transform: {
    '^.+\\.ts$': 'ts-jest',
  },
  collectCoverageFrom: [
    'src/**/*.{js,ts}',
    '!src/**/*.d.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

2.3 编写第一个测试

复制代码
// sum.js
function sum(a, b) {
  return a + b;
}

module.exports = sum;

// sum.test.js
const sum = require('./sum');

describe('sum函数测试', () => {
  test('1 + 2 应该等于 3', () => {
    expect(sum(1, 2)).toBe(3);
  });

  test('负数相加', () => {
    expect(sum(-1, -2)).toBe(-3);
  });

  test('浮点数相加', () => {
    expect(sum(0.1, 0.2)).toBeCloseTo(0.3);
  });
});

2.4 常用的匹配器(Matchers)

复制代码
// 基本匹配
expect(value).toBe(expected);           // 严格相等
expect(value).toEqual(expected);        // 深度相等
expect(value).not.toBe(expected);       // 取反

// 真值判断
expect(value).toBeTruthy();
expect(value).toBeFalsy();
expect(value).toBeNull();
expect(value).toBeUndefined();
expect(value).toBeDefined();

// 数字比较
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);

// 字符串匹配
expect(string).toMatch(/pattern/);
expect(string).toContain('substring');

// 数组和可迭代对象
expect(array).toContain(item);
expect(array).toHaveLength(3);

// 对象匹配
expect(object).toHaveProperty('key');
expect(object).toMatchObject({ key: value });

// 异常测试
expect(() => someFunction()).toThrow();
expect(() => someFunction()).toThrow('error message');

三、异步代码测试

3.1 Promise测试

复制代码
// fetchData.js
function fetchData() {
  return fetch('https://api.example.com/data')
    .then(response => response.json());
}

// fetchData.test.js
test('异步获取数据', () => {
  return fetchData().then(data => {
    expect(data).toHaveProperty('name');
  });
});

// 或使用async/await
test('异步获取数据 - async/await', async () => {
  const data = await fetchData();
  expect(data).toHaveProperty('name');
});

3.2 测试错误处理

复制代码
test('测试失败的请求', async () => {
  expect.assertions(1);
  try {
    await fetchData();
  } catch (error) {
    expect(error.message).toMatch('Network error');
  }
});

// 或使用rejects
test('测试失败的请求 - rejects', async () => {
  await expect(fetchData()).rejects.toThrow('Network error');
});

3.3 回调函数测试

复制代码
function fetchDataCallback(callback) {
  setTimeout(() => {
    callback({ data: 'peanut butter' });
  }, 1000);
}

test('回调函数测试', (done) => {
  function callback(data) {
    try {
      expect(data.data).toBe('peanut butter');
      done();
    } catch (error) {
      done(error);
    }
  }

  fetchDataCallback(callback);
});

四、Mock功能详解

4.1 Mock函数

复制代码
// 创建Mock函数
const mockFn = jest.fn();

// 使用Mock函数
mockFn('arg1', 'arg2');
mockFn('arg3');

// 检查调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('arg3');

// 设置返回值
mockFn.mockReturnValue(42);
mockFn.mockReturnValueOnce(1).mockReturnValueOnce(2);

// 设置实现
mockFn.mockImplementation(arg => arg * 2);

4.2 Mock模块

复制代码
// users.js
import axios from 'axios';

export async function getUsers() {
  const response = await axios.get('/api/users');
  return response.data;
}

// users.test.js
import axios from 'axios';
import { getUsers } from './users';

jest.mock('axios');

test('获取用户列表', async () => {
  const users = [{ id: 1, name: 'Alice' }];
  axios.get.mockResolvedValue({ data: users });

  const result = await getUsers();
  expect(result).toEqual(users);
  expect(axios.get).toHaveBeenCalledWith('/api/users');
});

4.3 部分Mock

复制代码
// 只Mock模块的某些部分
jest.mock('./module', () => ({
  ...jest.requireActual('./module'),
  functionToMock: jest.fn(),
}));

4.4 Mock定时器

复制代码
// timer.js
export function delayedGreeting(name, delay) {
  setTimeout(() => {
    console.log(`Hello, ${name}!`);
  }, delay);
}

// timer.test.js
jest.useFakeTimers();

test('延迟问候', () => {
  const spy = jest.spyOn(console, 'log');
  
  delayedGreeting('Alice', 1000);
  
  // 快进1秒
  jest.advanceTimersByTime(1000);
  
  expect(spy).toHaveBeenCalledWith('Hello, Alice!');
  
  spy.mockRestore();
});

五、React Testing Library实战

5.1 为什么选择Testing Library?

Testing Library遵循的核心原则是"测试你的软件使用方式",它鼓励编写更贴近用户行为的测试,而不是测试实现细节。

核心理念

  • 测试应该关注用户如何使用应用
  • 避免测试组件内部实现
  • 通过可访问性查询元素,提高代码质量

5.2 安装配置

复制代码
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

// setupTests.js
import '@testing-library/jest-dom';

5.3 基础组件测试

复制代码
// Button.jsx
export function Button({ onClick, children, disabled = false }) {
  return (
    <button onClick={onClick} disabled={disabled}>
      {children}
    </button>
  );
}

// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

describe('Button组件', () => {
  test('渲染按钮文本', () => {
    render(<Button>点击我</Button>);
    expect(screen.getByText('点击我')).toBeInTheDocument();
  });

  test('点击触发回调', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();
    
    render(<Button onClick={handleClick}>点击</Button>);
    
    await user.click(screen.getByText('点击'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('禁用状态不可点击', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();
    
    render(<Button onClick={handleClick} disabled>点击</Button>);
    
    await user.click(screen.getByText('点击'));
    expect(handleClick).not.toHaveBeenCalled();
  });
});

5.4 查询元素的方法

复制代码
// 按角色查询(推荐)
screen.getByRole('button', { name: /submit/i });
screen.getByRole('textbox', { name: /username/i });

// 按标签文本查询
screen.getByLabelText('用户名');

// 按占位符查询
screen.getByPlaceholderText('请输入用户名');

// 按文本内容查询
screen.getByText('登录');
screen.getByText(/submit/i); // 正则表达式

// 按测试ID查询(最后的选择)
screen.getByTestId('submit-button');

// 查询变体
// getBy*: 找不到抛出错误
// queryBy*: 找不到返回null
// findBy*: 异步查询,返回Promise

// 多个元素
screen.getAllByRole('listitem');
screen.queryAllByRole('listitem');
screen.findAllByRole('listitem');

5.5 测试表单交互

复制代码
// LoginForm.jsx
import { useState } from 'react';

export function LoginForm({ onSubmit }) {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    
    if (!username || !password) {
      setError('用户名和密码不能为空');
      return;
    }
    
    onSubmit({ username, password });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="username">用户名</label>
        <input
          id="username"
          type="text"
          value={username}
          onChange={(e) => setUsername(e.target.value)}
        />
      </div>
      
      <div>
        <label htmlFor="password">密码</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
        />
      </div>
      
      {error && <div role="alert">{error}</div>}
      
      <button type="submit">登录</button>
    </form>
  );
}

// LoginForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';

describe('LoginForm组件', () => {
  test('成功提交表单', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();
    
    render(<LoginForm onSubmit={handleSubmit} />);
    
    // 填写表单
    await user.type(screen.getByLabelText('用户名'), 'alice');
    await user.type(screen.getByLabelText('密码'), 'password123');
    
    // 提交
    await user.click(screen.getByRole('button', { name: '登录' }));
    
    // 验证
    expect(handleSubmit).toHaveBeenCalledWith({
      username: 'alice',
      password: 'password123',
    });
  });

  test('空表单提交显示错误', async () => {
    const user = userEvent.setup();
    const handleSubmit = jest.fn();
    
    render(<LoginForm onSubmit={handleSubmit} />);
    
    await user.click(screen.getByRole('button', { name: '登录' }));
    
    expect(screen.getByRole('alert')).toHaveTextContent('用户名和密码不能为空');
    expect(handleSubmit).not.toHaveBeenCalled();
  });
});

5.6 测试异步组件

复制代码
// UserList.jsx
import { useState, useEffect } from 'react';

export function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        setUsers(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return <div>加载中...</div>;
  if (error) return <div>错误:{error}</div>;

  return (
    <ul>
      {users.map(user => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

// UserList.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserList } from './UserList';

// Mock fetch
global.fetch = jest.fn();

describe('UserList组件', () => {
  beforeEach(() => {
    fetch.mockClear();
  });

  test('成功加载用户列表', async () => {
    const mockUsers = [
      { id: 1, name: 'Alice' },
      { id: 2, name: 'Bob' },
    ];

    fetch.mockResolvedValueOnce({
      json: async () => mockUsers,
    });

    render(<UserList />);

    // 检查加载状态
    expect(screen.getByText('加载中...')).toBeInTheDocument();

    // 等待数据加载
    await waitFor(() => {
      expect(screen.getByText('Alice')).toBeInTheDocument();
    });

    expect(screen.getByText('Bob')).toBeInTheDocument();
    expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
  });

  test('加载失败显示错误', async () => {
    fetch.mockRejectedValueOnce(new Error('Network error'));

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByText(/错误:Network error/)).toBeInTheDocument();
    });
  });
});

5.7 测试自定义Hooks

复制代码
// useCounter.js
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount(c => c + 1);
  }, []);

  const decrement = useCallback(() => {
    setCount(c => c - 1);
  }, []);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  return { count, increment, decrement, reset };
}

// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';

describe('useCounter Hook', () => {
  test('初始值为0', () => {
    const { result } = renderHook(() => useCounter());
    expect(result.current.count).toBe(0);
  });

  test('可以设置初始值', () => {
    const { result } = renderHook(() => useCounter(10));
    expect(result.current.count).toBe(10);
  });

  test('increment增加计数', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });

  test('decrement减少计数', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(4);
  });

  test('reset重置到初始值', () => {
    const { result } = renderHook(() => useCounter(10));
    
    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });
});

六、高级测试技巧

6.1 测试Context

复制代码
// ThemeContext.jsx
import { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

// ThemeButton.jsx
export function ThemeButton() {
  const { theme, setTheme } = useTheme();

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      当前主题:{theme}
    </button>
  );
}

// ThemeButton.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider } from './ThemeContext';
import { ThemeButton } from './ThemeButton';

describe('ThemeButton组件', () => {
  test('切换主题', async () => {
    const user = userEvent.setup();
    
    render(
      <ThemeProvider>
        <ThemeButton />
      </ThemeProvider>
    );

    expect(screen.getByText('当前主题:light')).toBeInTheDocument();

    await user.click(screen.getByRole('button'));

    expect(screen.getByText('当前主题:dark')).toBeInTheDocument();
  });
});

6.2 快照测试

复制代码
// Card.test.jsx
import { render } from '@testing-library/react';
import { Card } from './Card';

test('Card组件快照', () => {
  const { container } = render(
    <Card title="标题" content="内容" />
  );
  
  expect(container.firstChild).toMatchSnapshot();
});

// 更新快照:jest -u

6.3 测试错误边界

复制代码
// ErrorBoundary.jsx
import { Component } from 'react';

export class ErrorBoundary extends Component {
  state = { hasError: false, error: null };

  static getDerivedStateFromError(error) {
    return { hasError: true, error };
  }

  render() {
    if (this.state.hasError) {
      return <div role="alert">出错了:{this.state.error.message}</div>;
    }

    return this.props.children;
  }
}

// ErrorBoundary.test.jsx
import { render, screen } from '@testing-library/react';
import { ErrorBoundary } from './ErrorBoundary';

function ProblematicComponent() {
  throw new Error('测试错误');
}

test('捕获错误并显示', () => {
  // 抑制错误输出
  const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

  render(
    <ErrorBoundary>
      <ProblematicComponent />
    </ErrorBoundary>
  );

  expect(screen.getByRole('alert')).toHaveTextContent('出错了:测试错误');

  spy.mockRestore();
});

6.4 性能测试

复制代码
import { render } from '@testing-library/react';
import { performance } from 'perf_hooks';

test('组件渲染性能', () => {
  const start = performance.now();
  
  render(<ExpensiveComponent data={largeDataset} />);
  
  const end = performance.now();
  const renderTime = end - start;
  
  expect(renderTime).toBeLessThan(100); // 渲染时间少于100ms
});

七、测试最佳实践

7.1 AAA模式

遵循Arrange(准备)、Act(执行)、Assert(断言)模式:

复制代码
test('用户登录流程', async () => {
  // Arrange - 准备测试数据和环境
  const user = userEvent.setup();
  const mockLogin = jest.fn();
  render(<LoginForm onLogin={mockLogin} />);

  // Act - 执行操作
  await user.type(screen.getByLabelText('用户名'), 'alice');
  await user.type(screen.getByLabelText('密码'), 'pass123');
  await user.click(screen.getByRole('button', { name: '登录' }));

  // Assert - 验证结果
  expect(mockLogin).toHaveBeenCalledWith({
    username: 'alice',
    password: 'pass123',
  });
});

7.2 测试命名规范

复制代码
// 好的命名
test('空用户名提交时显示错误消息', () => {});
test('点击删除按钮后移除项目', () => {});

// 避免的命名
test('测试1', () => {});
test('应该工作', () => {});

7.3 避免测试实现细节

复制代码
// ❌ 不好的测试 - 测试实现细节
test('状态更新', () => {
  const { result } = renderHook(() => useState(0));
  expect(result.current[0]).toBe(0);
});

// ✅ 好的测试 - 测试用户行为
test('点击按钮增加计数', async () => {
  render(<Counter />);
  await userEvent.click(screen.getByRole('button', { name: '+' }));
  expect(screen.getByText('计数:1')).toBeInTheDocument();
});

7.4 使用测试辅助函数

复制代码
// testUtils.js
import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeContext';

export function renderWithTheme(ui, options) {
  return render(
    <ThemeProvider>
      {ui}
    </ThemeProvider>,
    options
  );
}

// 使用
test('带主题的组件', () => {
  renderWithTheme(<MyComponent />);
});

7.5 合理使用beforeEach和afterEach

复制代码
describe('数据库操作', () => {
  let db;

  beforeEach(() => {
    db = createTestDatabase();
  });

  afterEach(() => {
    db.close();
  });

  test('插入数据', () => {
    db.insert({ name: 'Alice' });
    expect(db.findByName('Alice')).toBeDefined();
  });
});

7.6 测试覆盖率目标

复制代码
// jest.config.js
module.exports = {
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.tsx',
    '!src/reportWebVitals.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 70,
      lines: 70,
      statements: 70,
    },
  },
};

覆盖率建议

  • 核心业务逻辑:90%+
  • UI组件:80%+
  • 工具函数:100%
  • 整体项目:70%+

但要记住:高覆盖率≠高质量测试,测试质量比数字更重要。

八、CI/CD集成

8.1 GitHub Actions示例

复制代码
# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '18'
        cache: 'npm'
    
    - name: Install dependencies
      run: npm ci
    
    - name: Run tests
      run: npm test -- --coverage
    
    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        files: ./coverage/lcov.info

8.2 package.json脚本

复制代码
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:ci": "jest --ci --coverage --maxWorkers=2"
  }
}

九、常见问题与解决方案

9.1 测试运行缓慢

问题:测试套件执行时间过长

解决方案

复制代码
// 使用并行运行
jest --maxWorkers=4

// 只运行改变的测试
jest --onlyChanged

// 关闭不必要的coverage
jest --no-coverage

9.2 Mock不生效

问题:Mock的模块没有被正确替换

解决方案

复制代码
// 确保jest.mock在最顶部
jest.mock('./module');

// 对于ES模块,使用正确的语法
jest.mock('./module', () => ({
  __esModule: true,
  default: jest.fn(),
}));

9.3 异步测试超时

问题:异步测试超过默认5秒超时

解决方案

复制代码
// 方法1:增加单个测试超时时间
test('长时间运行的测试', async () => {
  // ...
}, 10000); // 10秒

// 方法2:设置全局超时
jest.setTimeout(10000);

9.4 清理测试副作用

问题:测试之间相互影响

解决方案

复制代码
afterEach(() => {
  // 清理所有Mock
  jest.clearAllMocks();
  
  // 重置所有Mock
  jest.resetAllMocks();
  
  // 清理DOM
  cleanup();
});
相关推荐
U***49832 小时前
前端组件单元测试模拟数据,Mock Service Worker
前端·单元测试
ZT_KeBei2 小时前
前端调试利器——pageSpy的使用简易指南
前端
少卿2 小时前
PerformanceObserver 性能条目类型(Entry Types)
前端·javascript
宇余2 小时前
ES2025新特性实战:5分钟get前端高频实用语法
前端·typescript
励扬程序2 小时前
Cocos Creator 3.8 实现指定Node节点截图功能教程
前端·cocos creator
jenchoi4132 小时前
【2025-11-15】软件供应链安全日报:最新漏洞预警与投毒预警情报汇总
前端·网络·安全·网络安全·npm·node.js
进击的野人2 小时前
防抖与节流:优化前端性能的两种关键技术
前端·javascript
小高0072 小时前
别再滥用 Base64 了——Blob 才是前端减负的正确姿势
前端·javascript·面试
黑羽同学2 小时前
Fix: 修复AI聊天输入框Safari回车发送bug
前端·javascript·dom