React Testing完全指南:Jest、React Testing Library实战

在现代前端开发中,测试是确保代码质量和应用稳定性的关键环节。本文将深入探讨如何使用Jest和React Testing Library构建完整的React应用测试体系。

目录

  1. 测试基础概念
  2. Jest测试框架
  3. React Testing Library介绍
  4. 环境搭建
  5. 基础组件测试
  6. 用户交互测试
  7. 异步操作测试
  8. Mock和Spy
  9. 自定义Hook测试
  10. 集成测试
  11. 测试最佳实践

测试基础概念

测试类型

单元测试(Unit Testing)

  • 测试单个组件或函数的功能
  • 快速执行,易于调试
  • 应该占测试套件的大部分

集成测试(Integration Testing)

  • 测试多个组件协同工作
  • 验证组件间的交互
  • 更接近真实用户场景

端到端测试(E2E Testing)

  • 模拟真实用户操作
  • 测试完整的用户流程
  • 执行较慢,但提供最高信心

测试金字塔

复制代码
    /\
   /E2E\     - 少量,覆盖关键用户流程
  /______\
 /Integration\ - 适量,测试组件交互
/______________\
   Unit Tests   - 大量,快速反馈

Jest测试框架

Jest是Facebook开发的JavaScript测试框架,提供了完整的测试解决方案。

核心特性

  • 零配置:开箱即用
  • 快照测试:自动生成和比较组件渲染结果
  • 内置断言:丰富的匹配器
  • 代码覆盖率:无需额外配置
  • 并行执行:提高测试速度

基本语法

复制代码
// 基础测试结构
describe('Calculator', () => {
  test('should add two numbers', () => {
    expect(add(2, 3)).toBe(5);
  });

  test('should handle negative numbers', () => {
    expect(add(-1, 1)).toBe(0);
  });
});

常用匹配器

复制代码
// 基本匹配器
expect(value).toBe(4); // 严格相等
expect(value).toEqual({name: 'John'}); // 深度相等
expect(value).toBeNull(); // null值
expect(value).toBeUndefined(); // undefined值
expect(value).toBeTruthy(); // 真值
expect(value).toBeFalsy(); // 假值

// 数字匹配器
expect(value).toBeGreaterThan(3);
expect(value).toBeCloseTo(0.3);

// 字符串匹配器
expect('team').toMatch(/I/);
expect('Christoph').toMatch('stop');

// 数组匹配器
expect(['Alice', 'Bob', 'Eve']).toContain('Alice');

// 异常匹配器
expect(() => {
  throw new Error('Wrong!');
}).toThrow('Wrong!');

React Testing Library介绍

React Testing Library基于"测试应该尽可能接近用户使用软件的方式"的理念设计。

核心原则

  1. 用户为中心:通过用户能看到和交互的方式测试
  2. 实现细节无关:不测试组件内部状态或方法
  3. 可访问性优先:鼓励编写可访问的组件

查询优先级

复制代码
// 推荐优先级(从高到低)

// 1. 对所有人可访问的查询
getByRole('button', {name: /submit/i})
getByLabelText(/username/i)
getByPlaceholderText(/enter username/i)
getByText(/hello world/i)

// 2. 语义化查询
getByAltText(/profile picture/i)
getByTitle(/close/i)

// 3. 测试ID(最后选择)
getByTestId('submit-button')

环境搭建

安装依赖

复制代码
# 如果使用Create React App,已内置所需依赖
npx create-react-app my-app

# 手动安装
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

配置文件

复制代码
// src/setupTests.js
import '@testing-library/jest-dom';

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapping: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx}',
    '!src/index.js',
    '!src/reportWebVitals.js',
  ],
};

基础组件测试

简单组件测试

复制代码
// components/Button.js
import React from 'react';

const Button = ({ children, onClick, disabled = false, variant = 'primary' }) => {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
      data-testid="custom-button"
    >
      {children}
    </button>
  );
};

export default Button;

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

describe('Button Component', () => {
  test('renders button with text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  test('calls onClick when clicked', async () => {
    const user = userEvent.setup();
    const handleClick = jest.fn();
    
    render(<Button onClick={handleClick}>Click me</Button>);
    
    await user.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByRole('button')).toBeDisabled();
  });

  test('applies correct CSS class for variant', () => {
    render(<Button variant="secondary">Click me</Button>);
    expect(screen.getByRole('button')).toHaveClass('btn-secondary');
  });
});

表单组件测试

复制代码
// components/LoginForm.js
import React, { useState } from 'react';

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

  const handleSubmit = (e) => {
    e.preventDefault();
    setError('');

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

export default LoginForm;

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

describe('LoginForm', () => {
  test('renders form fields', () => {
    render(<LoginForm onSubmit={jest.fn()} />);
    
    expect(screen.getByLabelText(/用户名/i)).toBeInTheDocument();
    expect(screen.getByLabelText(/密码/i)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: /登录/i })).toBeInTheDocument();
  });

  test('shows error when submitting empty form', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={jest.fn()} />);
    
    await user.click(screen.getByRole('button', { name: /登录/i }));
    
    expect(screen.getByRole('alert')).toHaveTextContent('请填写所有字段');
  });

  test('calls onSubmit with form data when valid', async () => {
    const user = userEvent.setup();
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);
    
    await user.type(screen.getByLabelText(/用户名/i), 'testuser');
    await user.type(screen.getByLabelText(/密码/i), 'password123');
    await user.click(screen.getByRole('button', { name: /登录/i }));
    
    expect(mockSubmit).toHaveBeenCalledWith({
      username: 'testuser',
      password: 'password123'
    });
  });
});

用户交互测试

模拟用户事件

复制代码
import userEvent from '@testing-library/user-event';

// 点击事件
await user.click(element);

// 输入文本
await user.type(input, 'hello world');

// 选择选项
await user.selectOptions(select, 'option1');

// 键盘事件
await user.keyboard('{Enter}');
await user.keyboard('{Escape}');

// 复合操作
await user.clear(input);
await user.tab();

复杂交互示例

复制代码
// components/TodoList.js
import React, { useState } from 'react';

const TodoList = () => {
  const [todos, setTodos] = useState([]);
  const [inputValue, setInputValue] = useState('');

  const addTodo = () => {
    if (inputValue.trim()) {
      setTodos([...todos, { id: Date.now(), text: inputValue, completed: false }]);
      setInputValue('');
    }
  };

  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    ));
  };

  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };

  return (
    <div>
      <div>
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="添加新任务"
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
        />
        <button onClick={addTodo}>添加</button>
      </div>
      
      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
              aria-label={`标记 "${todo.text}" 为${todo.completed ? '未完成' : '已完成'}`}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

export default TodoList;

// components/__tests__/TodoList.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoList from '../TodoList';

describe('TodoList', () => {
  test('adds new todo when button clicked', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    const input = screen.getByPlaceholderText(/添加新任务/i);
    const addButton = screen.getByRole('button', { name: /添加/i });
    
    await user.type(input, '学习React测试');
    await user.click(addButton);
    
    expect(screen.getByText('学习React测试')).toBeInTheDocument();
    expect(input).toHaveValue('');
  });

  test('adds todo when Enter key pressed', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    const input = screen.getByPlaceholderText(/添加新任务/i);
    
    await user.type(input, '学习Jest');
    await user.keyboard('{Enter}');
    
    expect(screen.getByText('学习Jest')).toBeInTheDocument();
  });

  test('toggles todo completion', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    // 添加任务
    await user.type(screen.getByPlaceholderText(/添加新任务/i), '测试任务');
    await user.click(screen.getByRole('button', { name: /添加/i }));
    
    // 切换完成状态
    const checkbox = screen.getByRole('checkbox');
    await user.click(checkbox);
    
    expect(checkbox).toBeChecked();
    expect(screen.getByText('测试任务')).toHaveStyle('text-decoration: line-through');
  });

  test('deletes todo', async () => {
    const user = userEvent.setup();
    render(<TodoList />);
    
    // 添加任务
    await user.type(screen.getByPlaceholderText(/添加新任务/i), '要删除的任务');
    await user.click(screen.getByRole('button', { name: /添加/i }));
    
    // 删除任务
    await user.click(screen.getByRole('button', { name: /删除/i }));
    
    expect(screen.queryByText('要删除的任务')).not.toBeInTheDocument();
  });
});

异步操作测试

API调用测试

复制代码
// services/api.js
export const fetchUser = async (id) => {
  const response = await fetch(`/api/users/${id}`);
  if (!response.ok) {
    throw new Error('用户不存在');
  }
  return response.json();
};

// components/UserProfile.js
import React, { useState, useEffect } from 'react';
import { fetchUser } from '../services/api';

const UserProfile = ({ userId }) => {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const loadUser = async () => {
      try {
        setLoading(true);
        setError(null);
        const userData = await fetchUser(userId);
        setUser(userData);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    if (userId) {
      loadUser();
    }
  }, [userId]);

  if (loading) return <div>加载中...</div>;
  if (error) return <div role="alert">错误: {error}</div>;
  if (!user) return <div>用户不存在</div>;

  return (
    <div>
      <h2>{user.name}</h2>
      <p>邮箱: {user.email}</p>
      <p>电话: {user.phone}</p>
    </div>
  );
};

export default UserProfile;

// components/__tests__/UserProfile.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserProfile from '../UserProfile';
import { fetchUser } from '../../services/api';

// Mock API调用
jest.mock('../../services/api');
const mockFetchUser = fetchUser as jest.MockedFunction<typeof fetchUser>;

describe('UserProfile', () => {
  beforeEach(() => {
    mockFetchUser.mockClear();
  });

  test('shows loading state initially', () => {
    mockFetchUser.mockImplementation(() => new Promise(() => {})); // 永不resolve
    
    render(<UserProfile userId="1" />);
    
    expect(screen.getByText(/加载中/i)).toBeInTheDocument();
  });

  test('displays user data when loaded successfully', async () => {
    const mockUser = {
      id: '1',
      name: '张三',
      email: 'zhangsan@example.com',
      phone: '123-456-7890'
    };
    
    mockFetchUser.mockResolvedValue(mockUser);
    
    render(<UserProfile userId="1" />);
    
    await waitFor(() => {
      expect(screen.getByText('张三')).toBeInTheDocument();
    });
    
    expect(screen.getByText('邮箱: zhangsan@example.com')).toBeInTheDocument();
    expect(screen.getByText('电话: 123-456-7890')).toBeInTheDocument();
  });

  test('displays error when API call fails', async () => {
    mockFetchUser.mockRejectedValue(new Error('用户不存在'));
    
    render(<UserProfile userId="999" />);
    
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('错误: 用户不存在');
    });
  });

  test('refetches user when userId changes', async () => {
    const { rerender } = render(<UserProfile userId="1" />);
    
    await waitFor(() => {
      expect(mockFetchUser).toHaveBeenCalledWith('1');
    });
    
    rerender(<UserProfile userId="2" />);
    
    await waitFor(() => {
      expect(mockFetchUser).toHaveBeenCalledWith('2');
    });
    
    expect(mockFetchUser).toHaveBeenCalledTimes(2);
  });
});

延时操作测试

复制代码
// components/SearchInput.js
import React, { useState, useEffect } from 'react';

const SearchInput = ({ onSearch, delay = 300 }) => {
  const [query, setQuery] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => {
      if (query) {
        onSearch(query);
      }
    }, delay);

    return () => clearTimeout(timer);
  }, [query, delay, onSearch]);

  return (
    <input
      type="text"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="搜索..."
    />
  );
};

export default SearchInput;

// components/__tests__/SearchInput.test.js
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchInput from '../SearchInput';

// 启用fake timers
jest.useFakeTimers();

describe('SearchInput', () => {
  afterEach(() => {
    jest.clearAllTimers();
  });

  test('debounces search calls', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    const mockSearch = jest.fn();
    
    render(<SearchInput onSearch={mockSearch} delay={300} />);
    
    const input = screen.getByPlaceholderText(/搜索/i);
    
    // 快速输入多个字符
    await user.type(input, 'react');
    
    // 此时还没有调用搜索
    expect(mockSearch).not.toHaveBeenCalled();
    
    // 快进时间
    jest.advanceTimersByTime(300);
    
    // 现在应该调用搜索
    await waitFor(() => {
      expect(mockSearch).toHaveBeenCalledWith('react');
    });
    expect(mockSearch).toHaveBeenCalledTimes(1);
  });

  test('cancels previous timer when typing quickly', async () => {
    const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
    const mockSearch = jest.fn();
    
    render(<SearchInput onSearch={mockSearch} delay={300} />);
    
    const input = screen.getByPlaceholderText(/搜索/i);
    
    // 输入 'r'
    await user.type(input, 'r');
    jest.advanceTimersByTime(100);
    
    // 继续输入 'e'
    await user.type(input, 'e');
    jest.advanceTimersByTime(100);
    
    // 继续输入 'act'
    await user.type(input, 'act');
    
    // 快进到延时结束
    jest.advanceTimersByTime(300);
    
    // 应该只调用一次,使用最终的查询
    await waitFor(() => {
      expect(mockSearch).toHaveBeenCalledWith('react');
    });
    expect(mockSearch).toHaveBeenCalledTimes(1);
  });
});

Mock和Spy

模拟函数

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

// 设置返回值
mockFn.mockReturnValue('mocked value');
mockFn.mockReturnValueOnce('first call');
mockFn.mockReturnValueOnce('second call');

// 设置异步返回值
mockFn.mockResolvedValue('async value');
mockFn.mockRejectedValue(new Error('async error'));

// 设置实现
mockFn.mockImplementation((x) => x * 2);
mockFn.mockImplementationOnce((x) => x * 3);

// 验证调用
expect(mockFn).toHaveBeenCalled();
expect(mockFn).toHaveBeenCalledTimes(2);
expect(mockFn).toHaveBeenCalledWith('arg1', 'arg2');
expect(mockFn).toHaveBeenLastCalledWith('last arg');

模拟模块

复制代码
// 完全模拟模块
jest.mock('../utils/helper');

// 部分模拟模块
jest.mock('../utils/helper', () => ({
  ...jest.requireActual('../utils/helper'),
  formatDate: jest.fn(() => '2023-01-01'),
}));

// 自动模拟
jest.mock('../services/api');

// 手动模拟
jest.mock('../services/api', () => ({
  fetchData: jest.fn(),
  postData: jest.fn(),
}));

Spy函数

复制代码
// 监听对象方法
const obj = {
  method: () => 'original'
};

const spy = jest.spyOn(obj, 'method');
spy.mockReturnValue('mocked');

// 监听console.log
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();

// 恢复原始实现
spy.mockRestore();

自定义Hook测试

renderHook使用

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

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

  const increment = () => setCount(c => c + 1);
  const decrement = () => setCount(c => c - 1);
  const reset = () => setCount(initialValue);

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

export default useCounter;

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

describe('useCounter', () => {
  test('initializes with default value', () => {
    const { result } = renderHook(() => useCounter());
    
    expect(result.current.count).toBe(0);
  });

  test('initializes with custom value', () => {
    const { result } = renderHook(() => useCounter(10));
    
    expect(result.current.count).toBe(10);
  });

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

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

  test('resets to initial value', () => {
    const { result } = renderHook(() => useCounter(10));
    
    act(() => {
      result.current.increment();
      result.current.increment();
    });
    
    expect(result.current.count).toBe(12);
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(10);
  });
});

异步Hook测试

复制代码
// hooks/useFetch.js
import { useState, useEffect } from 'react';

const useFetch = (url) => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        const result = await response.json();
        setData(result);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    if (url) {
      fetchData();
    }
  }, [url]);

  return { data, loading, error };
};

export default useFetch;

// hooks/__tests__/useFetch.test.js
import { renderHook, waitFor } from '@testing-library/react';
import useFetch from '../useFetch';

// Mock fetch
global.fetch = jest.fn();
const mockFetch = fetch as jest.MockedFunction<typeof fetch>;

describe('useFetch', () => {
  beforeEach(() => {
    mockFetch.mockClear();
  });

  test('returns initial state', () => {
    const { result } = renderHook(() => useFetch(''));
    
    expect(result.current.data).toBeNull();
    expect(result.current.loading).toBe(true);
    expect(result.current.error).toBeNull();
  });

  test('fetches data successfully', async () => {
    const mockData = { id: 1, name: 'Test' };
    mockFetch.mockResolvedValue({
      ok: true,
      json: async () => mockData,
    } as Response);

    const { result } = renderHook(() => useFetch('/api/test'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toEqual(mockData);
    expect(result.current.error).toBeNull();
  });

  test('handles fetch error', async () => {
    mockFetch.mockResolvedValue({
      ok: false,
      status: 404,
    } as Response);

    const { result } = renderHook(() => useFetch('/api/not-found'));

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    expect(result.current.data).toBeNull();
    expect(result.current.error).toBe('HTTP error! status: 404');
  });

  test('refetches when URL changes', async () => {
    const { result, rerender } = renderHook(
      ({ url }) => useFetch(url),
      { initialProps: { url: '/api/test1' } }
    );

    await waitFor(() => {
      expect(result.current.loading).toBe(false);
    });

    rerender({ url: '/api/test2' });

    expect(result.current.loading).toBe(true);
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });
});

集成测试

多组件协作测试

复制代码
// components/ShoppingCart.js
import React, { useState } from 'react';
import ProductList from './ProductList';
import Cart from './Cart';

const ShoppingCart = () => {
  const [cartItems, setCartItems] = useState([]);

  const addToCart = (product) => {
    setCartItems(prev => {
      const existing = prev.find(item => item.id === product.id);
      if (existing) {
        return prev.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }
      return [...prev, { ...product, quantity: 1 }];
    });
  };

  const removeFromCart = (productId) => {
    setCartItems(prev => prev.filter(item => item.id !== productId));
  };

  const updateQuantity = (productId, quantity) => {
    if (quantity <= 0) {
      removeFromCart(productId);
      return;
    }
    setCartItems(prev =>
      prev.map(item =>
        item.id === productId ? { ...item, quantity } : item
      )
    );
  };

  return (
    <div>
      <h1>购物商城</h1>
      <div style={{ display: 'flex' }}>
        <ProductList onAddToCart={addToCart} />
        <Cart
          items={cartItems}
          onRemove={removeFromCart}
          onUpdateQuantity={updateQuantity}
        />
      </div>
    </div>
  );
};

export default ShoppingCart;

// components/__tests__/ShoppingCart.integration.test.js
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ShoppingCart from '../ShoppingCart';

describe('ShoppingCart Integration', () => {
  test('complete shopping flow', async () => {
    const user = userEvent.setup();
    render(<ShoppingCart />);

    // 验证初始状态
    expect(screen.getByText('购物商城')).toBeInTheDocument();
    expect(screen.getByText('购物车空空如也')).toBeInTheDocument();

    // 添加第一个商品
    const firstAddButton = screen.getAllByText('添加到购物车')[0];
    await user.click(firstAddButton);

    // 验证购物车更新
    expect(screen.queryByText('购物车空空如也')).not.toBeInTheDocument();
    expect(screen.getByText(/商品1/)).toBeInTheDocument();
    expect(screen.getByText(/数量: 1/)).toBeInTheDocument();

    // 再次添加同一商品
    await user.click(firstAddButton);
    expect(screen.getByText(/数量: 2/)).toBeInTheDocument();

    // 添加第二个商品
    const secondAddButton = screen.getAllByText('添加到购物车')[1];
    await user.click(secondAddButton);

    // 验证两个商品都在购物车中
    expect(screen.getByText(/商品1/)).toBeInTheDocument();
    expect(screen.getByText(/商品2/)).toBeInTheDocument();

    // 更新数量
    const quantityInput = screen.getAllByRole('spinbutton')[0];
    await user.clear(quantityInput);
    await user.type(quantityInput, '5');

    expect(screen.getByDisplayValue('5')).toBeInTheDocument();

    // 删除商品
    const removeButton = screen.getAllByText('删除')[0];
    await user.click(removeButton);

    expect(screen.queryByText(/商品1/)).not.toBeInTheDocument();
    expect(screen.getByText(/商品2/)).toBeInTheDocument();

    // 计算总价
    expect(screen.getByText(/总计:/)).toBeInTheDocument();
  });

  test('handles edge cases', async () => {
    const user = userEvent.setup();
    render(<ShoppingCart />);

    // 添加商品
    const addButton = screen.getAllByText('添加到购物车')[0];
    await user.click(addButton);

    // 将数量设为0(应该删除商品)
    const quantityInput = screen.getByRole('spinbutton');
    await user.clear(quantityInput);
    await user.type(quantityInput, '0');

    expect(screen.getByText('购物车空空如也')).toBeInTheDocument();
  });
});

测试最佳实践

1. 测试结构组织

复制代码
// 推荐的测试文件结构
describe('ComponentName', () => {
  // 设置和清理
  beforeEach(() => {
    // 通用设置
  });

  afterEach(() => {
    // 清理工作
  });

  // 按功能分组
  describe('rendering', () => {
    test('renders correctly with default props', () => {});
    test('renders correctly with custom props', () => {});
  });

  describe('user interactions', () => {
    test('handles click events', () => {});
    test('handles form submission', () => {});
  });

  describe('edge cases', () => {
    test('handles empty data', () => {});
    test('handles error states', () => {});
  });
});

2. 测试命名约定

复制代码
// 好的测试名称
test('shows error message when form is submitted with empty email', () => {});
test('updates cart total when item quantity changes', () => {});
test('disables submit button when request is pending', () => {});

// 避免的测试名称
test('test email validation', () => {});
test('cart functionality', () => {});
test('button state', () => {});

3. 断言最佳实践

复制代码
// 精确断言
expect(screen.getByRole('button')).toBeEnabled();
expect(screen.getByText('Success!')).toBeInTheDocument();

// 避免过于宽泛的断言
expect(container.firstChild).toBeTruthy(); // 不够具体

// 使用语义化查询
screen.getByRole('button', { name: /submit/i });
screen.getByLabelText(/email address/i);

// 避免实现细节
expect(component.state.isLoading).toBe(false); // 测试实现细节

4. 辅助函数

复制代码
// test-utils.js
import React from 'react';
import { render } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from '../store';

// 自定义渲染函数
export const renderWithProviders = (ui, options = {}) => {
  const Wrapper = ({ children }) => (
    <Provider store={store}>
      <BrowserRouter>
        {children}
      </BrowserRouter>
    </Provider>
  );

  return render(ui, { wrapper: Wrapper, ...options });
};

// 通用的等待函数
export const waitForLoadingToFinish = () => 
  waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument());

// 表单填写辅助函数
export const fillForm = async (user, formData) => {
  for (const [field, value] of Object.entries(formData)) {
    const input = screen.getByLabelText(new RegExp(field, 'i'));
    await user.clear(input);
    await user.type(input, value);
  }
};

5. 代码覆盖率

复制代码
# 运行测试并生成覆盖率报告
npm test -- --coverage

# 设置覆盖率阈值
# package.json
{
  "jest": {
    "collectCoverageFrom": [
      "src/**/*.{js,jsx}",
      "!src/index.js",
      "!src/reportWebVitals.js"
    ],
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    }
  }
}

6. 性能测试

复制代码
test('renders large list efficiently', () => {
  const largeData = Array.from({ length: 1000 }, (_, i) => ({
    id: i,
    name: `Item ${i}`
  }));

  const startTime = performance.now();
  render(<LargeList items={largeData} />);
  const endTime = performance.now();

  expect(endTime - startTime).toBeLessThan(100); // 100ms 以内
});

7. 快照测试

复制代码
test('matches snapshot', () => {
  const { container } = render(<Button variant="primary">Click me</Button>);
  expect(container.firstChild).toMatchSnapshot();
});

// 内联快照
test('renders correctly', () => {
  const { container } = render(<Button>Test</Button>);
  expect(container.firstChild).toMatchInlineSnapshot(`
    <button
      class="btn btn-primary"
      data-testid="custom-button"
    >
      Test
    </button>
  `);
});

总结

React测试是确保应用质量的重要环节。通过Jest和React Testing Library的组合,我们可以:

  • 编写用户导向的测试:测试用户实际看到和交互的内容
  • 保持测试稳定性:避免测试实现细节,专注于行为
  • 提高开发效率:快速反馈,及早发现问题
  • 增强重构信心:完善的测试覆盖让代码重构更安全
相关推荐
天天进步20154 小时前
React + TypeScript实战:类型安全的现代前端开发
安全·react.js·typescript
智慧的牛4 小时前
React简单例子
javascript·react.js
介次别错过5 小时前
React入门
前端·javascript·react.js
FserSuN6 小时前
React 标准 SPA 项目 入门学习记录
前端·学习·react.js
山有木兮木有枝_4 天前
前端性能优化:图片懒加载与组件缓存技术详解
前端·javascript·react.js
小lan猫4 天前
React学习笔记(二)
react.js
用户7678797737325 天前
后端转全栈之Next.js后端及配置
react.js·next.js
Johnny_FEer5 天前
什么是 React 中的远程组件?
前端·react.js
艾小码5 天前
用了这么久React,你真的搞懂useEffect了吗?
前端·javascript·react.js