前端单元测试从入门到精通: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();
});
相关推荐
weixin_382395232 小时前
为小工厂量身打造:本地部署的物料管理系统带缺料计算
前端·制造
__zRainy__2 小时前
解决pnpm v10+不自动构建
前端·pnpm·工程化
猫猫不是喵喵.3 小时前
Vue3 Props 属性
前端·javascript·vue.js
醉城夜风~4 小时前
CSS元素显示模式(display)
前端·css
AI大模型-小华5 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子7 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅7 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni7 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学9 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端