React 测试入门:Jest + Testing Library 完整指南

14 阅读4分钟

引言

在 React 开发中,测试是保证代码质量的重要环节。良好的测试体系能够帮助我们:

  • 快速发现回归问题
  • 安全重构代码
  • 文档化组件行为
  • 提升开发信心

本文将介绍 React 测试的核心工具 Jest 和 Testing Library,并通过实战示例带你入门。

测试工具简介

Jest

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

  • 零配置:开箱即用
  • 快照测试:自动检测 UI 变化
  • Mock 功能:轻松模拟依赖
  • 并行 执行:提升测试速度

Testing Library

Testing Library 是一套测试工具集合,核心理念是:

测试应该像用户一样使用你的应用

它鼓励我们测试组件的行为而非实现细节,主要包含:

  • @testing-library/react:React 组件测试
  • @testing-library/user-event:模拟用户交互
  • @testing-library/jest-dom:DOM 匹配器

环境搭建

首先安装必要的依赖:

npm install -D jest @testing-library/react @testing-library/jest-dom @testing-library/user-event

创建 jest.setup.js 配置文件:

import '@testing-library/jest-dom';

// 全局匹配器
expect.extend({
  // 自定义匹配器
});

package.json 中添加测试脚本:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

基础测试示例

测试简单组件

假设我们有一个简单的 Button 组件:

// Button.jsx
export default function Button({ children, onClick, disabled }) {
  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 disabled onClick={handleClick}>禁用按钮</Button>);
    
    const button = screen.getByText('禁用按钮');
    expect(button).toBeDisabled();
    
    await user.click(button);
    expect(handleClick).not.toHaveBeenCalled();
  });

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

测试异步操作

实际项目中,组件经常涉及异步操作。Testing Library 提供了 findBywaitFor 来处理异步场景。

示例:数据获取组件

// UserProfile.jsx
import { useState, useEffect } from 'react';

export default function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        setUser(data);
        setLoading(false);
      });
  }, [userId]);

  if (loading) return <div>加载中...</div>;
  if (!user) return <div>未找到用户</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

异步测试写法

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

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

describe('UserProfile 组件', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('显示加载状态', () => {
    fetch.mockResolvedValueOnce({
      json: async () => ({ name: '张三', email: 'zhang@example.com' })
    });

    render(<UserProfile userId="123" />);
    
    expect(screen.getByText('加载中...')).toBeInTheDocument();
  });

  test('成功加载后显示用户信息', async () => {
    fetch.mockResolvedValueOnce({
      json: async () => ({ name: '张三', email: 'zhang@example.com' })
    });

    render(<UserProfile userId="123" />);
    
    // 使用 findBy 等待元素出现
    const nameElement = await screen.findByText('张三');
    expect(nameElement).toBeInTheDocument();
    expect(screen.getByText('zhang@example.com')).toBeInTheDocument();
  });

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

    render(<UserProfile userId="123" />);
    
    await waitFor(() => {
      expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
    });
  });
});

测试自定义 Hooks

自定义 Hooks 的测试需要特殊处理。我们可以创建一个测试组件来包裹 Hook。

// useCounter.js
import { useState, useCallback } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = useCallback(() => {
    setCount(prev => prev + 1);
  }, []);
  
  const decrement = useCallback(() => {
    setCount(prev => prev - 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);
  });
});

常用匹配器

Testing Library 配合 jest-dom 提供丰富的匹配器:

// 存在性检查
toBeInTheDocument();
not.toBeInTheDocument();

// 可见性检查
toBeVisible();
not.toBeVisible();

// 状态检查
toBeDisabled();
toBeEnabled();
toBeChecked();
toHaveFocus();

// 内容检查
toHaveTextContent('文本');
toHaveAttribute('href', '/link');
toHaveClass('active');
toHaveStyle({ color: 'red' });

// 数量检查
toHaveLength(3);

最佳实践

1. 测试行为而非实现

// ❌ 不推荐:测试实现细节
expect(component.props.className).toBe('btn-primary');

// ✅ 推荐:测试用户可见的行为
expect(screen.getByRole('button')).toHaveClass('btn-primary');

2. 使用语义化查询

// 优先级顺序
getByRole()        // 按 ARIA 角色
getByLabelText()   // 按标签文本
getByPlaceholderText() // 按占位符
getByText()        // 按文本内容
getByTestId()      // 最后选择(添加 data-testid)

3. 保持测试独立

// 每个测试应该独立运行
beforeEach(() => {
  jest.clearAllMocks();
  cleanup();
});

4. 测试边界情况

  • 空状态
  • 加载状态
  • 错误状态
  • 极端输入值

总结

React 测试是保证应用质量的关键环节。掌握 Jest 和 Testing Library 的核心用法,遵循"测试行为而非实现"的原则,能够帮助我们构建更可靠的应用。

核心要点回顾:

  1. 使用 Testing Library 模拟用户行为
  2. 优先使用语义化查询(getByRole 等)
  3. 异步操作使用 findBy 或 waitFor
  4. 测试 Hooks 使用 renderHook
  5. 保持测试独立、可维护

开始为你的 React 项目添加测试吧,这将是值得的投资!