测试React应用

高质量的测试是构建可靠React应用的关键。通过全面的测试覆盖,你可以自信地进行重构,确保应用稳定性和质量。

测试金字塔

测试金字塔描述了不同层次测试的理想分布:更多的单元测试,较少的集成测试,更少的端到端测试。

端到端测试 (E2E)

测试完整应用流程,模拟真实用户行为

5-10% 测试覆盖

集成测试

测试组件间的交互和集成

15-20% 测试覆盖

单元测试

测试独立的函数、组件和Hook

70-80% 测试覆盖

测试工具生态系统

基础

Jest

JavaScript测试框架,React默认测试工具

  • ✓ 零配置
  • ✓ 快速并行测试
  • ✓ 快照测试
  • ✓ 覆盖率报告
  • ✓ Mock和Spy支持
推荐

React Testing Library

用于测试React组件的工具库

  • ✓ 鼓励测试用户行为
  • ✓ 简单易用的API
  • ✓ 无障碍测试支持
  • ✓ 与Jest完美集成
  • ✓ 官方推荐
E2E

Cypress

端到端测试框架

  • ✓ 实时重载测试
  • ✓ 自动等待
  • ✓ 时间旅行调试
  • ✓ 截图和视频
  • ✓ 交互式测试运行
现代

Playwright

现代Web测试和自动化框架

  • ✓ 跨浏览器测试
  • ✓ 自动等待
  • ✓ 网络拦截
  • ✓ 并行测试
  • ✓ 多种语言支持
Mock

MSW

Mock Service Worker - API Mocking库

  • ✓ 拦截网络请求
  • ✓ 浏览器和Node.js
  • ✓ 真实HTTP语义
  • ✓ 开发和生产使用
  • ✓ TypeScript支持
Hook

Testing Library Hooks

测试React Hooks的工具

  • ✓ 独立测试Hook
  • ✓ 模拟Hook依赖
  • ✓ 异步Hook测试
  • ✓ 错误边界测试
  • ✓ 与React Testing Library集成

安装与配置

# 使用Create React App创建的应用已包含Jest和React Testing Library
npx create-react-app my-app --template typescript

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

# 安装类型定义(TypeScript)
npm install --save-dev @types/jest @types/testing-library__react

# 安装Cypress(端到端测试)
npm install --save-dev cypress

# 安装MSW(API Mocking)
npm install --save-dev msw

# package.json配置示例
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:e2e": "cypress run",
    "test:e2e:open": "cypress open"
  },
  "jest": {
    "testEnvironment": "jsdom",
    "setupFilesAfterEnv": ["<rootDir>/src/setupTests.js"],
    "collectCoverageFrom": [
      "src/**/*.{js,jsx,ts,tsx}",
      "!src/**/*.d.ts",
      "!src/index.js",
      "!src/reportWebVitals.js"
    ]
  }
}

测试运行演示

交互式测试运行器

测试运行器已就绪
点击上方按钮开始测试
测试覆盖率
覆盖率: 0%
被测试组件

测试React组件

1. 基础组件测试

组件代码
// Button.jsx
import React from 'react';

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

export default Button;
测试代码
// Button.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

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

    // 使用getByText查找元素
    const buttonElement = screen.getByText(/点击我/i);
    expect(buttonElement).toBeInTheDocument();
  });

  test('点击按钮触发回调', () => {
    const handleClick = jest.fn();

    render(<Button onClick={handleClick}>点击</Button>);

    // 使用getByTestId查找元素
    const button = screen.getByTestId('custom-button');
    fireEvent.click(button);

    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  test('禁用状态', () => {
    render(<Button disabled={true}>禁用按钮</Button>);

    const button = screen.getByTestId('custom-button');
    expect(button).toBeDisabled();
    expect(button).toHaveClass('custom-button');
  });

  test('快照测试', () => {
    const { container } = render(<Button>保存</Button>);
    expect(container.firstChild).toMatchSnapshot();
  });
});

2. 表单组件测试

表单组件
// LoginForm.jsx
import React, { useState } from 'react';

const LoginForm = ({ onSubmit }) => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});

  const validate = () => {
    const newErrors = {};

    if (!email) newErrors.email = '邮箱不能为空';
    if (!password) newErrors.password = '密码不能为空';
    if (password && password.length < 6) {
      newErrors.password = '密码至少6位';
    }

    return newErrors;
  };

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

    const validationErrors = validate();
    if (Object.keys(validationErrors).length > 0) {
      setErrors(validationErrors);
      return;
    }

    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit} data-testid="login-form">
      <div>
        <label htmlFor="email">邮箱</label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          data-testid="email-input"
        />
        {errors.email && (
          <span data-testid="email-error">{errors.email}</span>
        )}
      </div>

      <div>
        <label htmlFor="password">密码</label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          data-testid="password-input"
        />
        {errors.password && (
          <span data-testid="password-error">{errors.password}</span>
        )}
      </div>

      <button type="submit" data-testid="submit-button">
        登录
      </button>
    </form>
  );
};

export default LoginForm;
表单测试
// LoginForm.test.js
import React from 'react';
import {
  render,
  screen,
  fireEvent,
  waitFor
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';

describe('LoginForm组件', () => {
  test('渲染表单元素', () => {
    render(<LoginForm />);

    expect(screen.getByTestId('login-form')).toBeInTheDocument();
    expect(screen.getByTestId('email-input')).toBeInTheDocument();
    expect(screen.getByTestId('password-input')).toBeInTheDocument();
    expect(screen.getByTestId('submit-button')).toBeInTheDocument();
  });

  test('表单提交成功', async () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);

    // 使用userEvent模拟用户输入
    await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');
    await userEvent.type(screen.getByTestId('password-input'), 'password123');

    // 提交表单
    fireEvent.click(screen.getByTestId('submit-button'));

    // 验证回调被调用
    await waitFor(() => {
      expect(mockSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123'
      });
    });
  });

  test('表单验证失败', async () => {
    const mockSubmit = jest.fn();
    render(<LoginForm onSubmit={mockSubmit} />);

    // 只输入邮箱,不输入密码
    await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');

    fireEvent.click(screen.getByTestId('submit-button'));

    // 验证错误信息显示
    await waitFor(() => {
      expect(screen.getByTestId('password-error')).toBeInTheDocument();
      expect(screen.getByTestId('password-error')).toHaveTextContent(
        '密码不能为空'
      );
    });

    expect(mockSubmit).not.toHaveBeenCalled();
  });

  test('密码长度验证', async () => {
    render(<LoginForm />);

    await userEvent.type(screen.getByTestId('email-input'), 'test@example.com');
    await userEvent.type(screen.getByTestId('password-input'), '123');

    fireEvent.click(screen.getByTestId('submit-button'));

    await waitFor(() => {
      expect(screen.getByTestId('password-error')).toHaveTextContent(
        '密码至少6位'
      );
    });
  });
});

3. 测试React Hooks

自定义Hook
// useCounter.js
import { useState, useCallback } from 'react';

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

  const increment = useCallback(() => {
    setCount(prev => {
      const newCount = prev + 1;
      setHistory(prevHistory => [...prevHistory, newCount]);
      return newCount;
    });
  }, []);

  const decrement = useCallback(() => {
    setCount(prev => {
      const newCount = prev - 1;
      setHistory(prevHistory => [...prevHistory, newCount]);
      return newCount;
    });
  }, []);

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

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

export default useCounter;
Hook测试
// useCounter.test.js
import { renderHook, act } from '@testing-library/react-hooks';
import useCounter from './useCounter';

describe('useCounter Hook', () => {
  test('应该正确初始化计数', () => {
    const { result } = renderHook(() => useCounter(10));

    expect(result.current.count).toBe(10);
    expect(result.current.history).toEqual([]);
  });

  test('增加计数应该工作', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
    expect(result.current.history).toEqual([1]);
  });

  test('减少计数应该工作', () => {
    const { result } = renderHook(() => useCounter(5));

    act(() => {
      result.current.decrement();
    });

    expect(result.current.count).toBe(4);
    expect(result.current.history).toEqual([4]);
  });

  test('重置计数应该工作', () => {
    const { result } = renderHook(() => useCounter(3));

    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.reset();
    });

    expect(result.current.count).toBe(3);
    expect(result.current.history).toEqual([]);
  });

  test('多次操作应该记录历史', () => {
    const { result } = renderHook(() => useCounter());

    act(() => {
      result.current.increment();
      result.current.increment();
      result.current.decrement();
    });

    expect(result.current.count).toBe(1);
    expect(result.current.history).toEqual([1, 2, 1]);
  });
});

// 使用Hook的组件测试
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';

function CounterComponent() {
  const { count, increment, decrement } = useCounter();

  return (
    <div>
      <span data-testid="count-display">{count}</span>
      <button data-testid="increment-btn" onClick={increment}>
        增加
      </button>
      <button data-testid="decrement-btn" onClick={decrement}>
        减少
      </button>
    </div>
  );
}

describe('使用useCounter的组件', () => {
  test('渲染初始计数', () => {
    render(<CounterComponent />);

    expect(screen.getByTestId('count-display')).toHaveTextContent('0');
  });

  test('点击按钮更新计数', () => {
    render(<CounterComponent />);

    fireEvent.click(screen.getByTestId('increment-btn'));
    expect(screen.getByTestId('count-display')).toHaveTextContent('1');

    fireEvent.click(screen.getByTestId('decrement-btn'));
    expect(screen.getByTestId('count-display')).toHaveTextContent('0');
  });
});

4. 测试异步操作

异步组件
// UserProfile.jsx
import React, { useState, useEffect } from 'react';
import axios from 'axios';

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

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await axios.get(`/api/users/${userId}`);
        setUser(response.data);
        setError(null);
      } catch (err) {
        setError('获取用户信息失败');
        console.error(err);
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]);

  if (loading) {
    return <div data-testid="loading-spinner">加载中...</div>;
  }

  if (error) {
    return <div data-testid="error-message">{error}</div>;
  }

  return (
    <div data-testid="user-profile">
      <h3 data-testid="user-name">{user.name}</h3>
      <p data-testid="user-email">邮箱: {user.email}</p>
      <p data-testid="user-role">角色: {user.role}</p>
    </div>
  );
};

export default UserProfile;
异步测试
// UserProfile.test.js
import React from 'react';
import {
  render,
  screen,
  waitFor,
  waitForElementToBeRemoved
} from '@testing-library/react';
import axios from 'axios';
import UserProfile from './UserProfile';

// Mock axios
jest.mock('axios');

describe('UserProfile组件', () => {
  const mockUser = {
    id: 1,
    name: '张三',
    email: 'zhangsan@example.com',
    role: '管理员'
  };

  beforeEach(() => {
    // 清除之前的mock调用
    jest.clearAllMocks();
  });

  test('加载状态', () => {
    axios.get.mockImplementation(() =>
      new Promise(() => {}) // 永不resolve的Promise
    );

    render(<UserProfile userId={1} />);

    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
  });

  test('成功加载用户数据', async () => {
    axios.get.mockResolvedValue({ data: mockUser });

    render(<UserProfile userId={1} />);

    // 等待加载状态消失
    await waitForElementToBeRemoved(() =>
      screen.queryByTestId('loading-spinner')
    );

    // 验证用户信息显示
    await waitFor(() => {
      expect(screen.getByTestId('user-name')).toHaveTextContent('张三');
      expect(screen.getByTestId('user-email')).toHaveTextContent(
        '邮箱: zhangsan@example.com'
      );
      expect(screen.getByTestId('user-role')).toHaveTextContent(
        '角色: 管理员'
      );
    });

    // 验证API调用
    expect(axios.get).toHaveBeenCalledTimes(1);
    expect(axios.get).toHaveBeenCalledWith('/api/users/1');
  });

  test('处理API错误', async () => {
    axios.get.mockRejectedValue(new Error('网络错误'));

    render(<UserProfile userId={1} />);

    await waitForElementToBeRemoved(() =>
      screen.queryByTestId('loading-spinner')
    );

    await waitFor(() => {
      expect(screen.getByTestId('error-message')).toBeInTheDocument();
      expect(screen.getByTestId('error-message')).toHaveTextContent(
        '获取用户信息失败'
      );
    });
  });

  test('组件卸载时取消请求', async () => {
    const cancelToken = {
      cancel: jest.fn()
    };

    axios.CancelToken = {
      source: jest.fn(() => ({
        token: 'mock-token',
        cancel: cancelToken.cancel
      }))
    };

    axios.get.mockResolvedValue({ data: mockUser });

    const { unmount } = render(<UserProfile userId={1} />);

    // 立即卸载组件
    unmount();

    // 验证取消函数被调用
    expect(cancelToken.cancel).toHaveBeenCalledWith(
      '组件卸载,取消请求'
    );
  });
});

// 使用MSW进行API Mocking
import { setupServer } from 'msw/node';
import { rest } from 'msw';

const server = setupServer(
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;

    if (id === '1') {
      return res(
        ctx.json({
          id: 1,
          name: '李四',
          email: 'lisi@example.com'
        })
      );
    }

    return res(
      ctx.status(404),
      ctx.json({ message: '用户不存在' })
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('使用MSW测试API调用', async () => {
  render(<UserProfile userId="1" />);

  await waitFor(() => {
    expect(screen.getByTestId('user-name')).toHaveTextContent('李四');
  });
});

5. 测试错误边界

错误边界组件
// ErrorBoundary.jsx
import React from 'react';

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false, error: null };
  }

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

  componentDidCatch(error, errorInfo) {
    // 可以在这里记录错误到日志服务
    console.error('ErrorBoundary捕获错误:', error, errorInfo);
  }

  resetError = () => {
    this.setState({ hasError: false, error: null });
  };

  render() {
    if (this.state.hasError) {
      return (
        <div data-testid="error-boundary">
          <h3>组件发生错误</h3>
          <p data-testid="error-message">
            {this.state.error.toString()}
          </p>
          <button
            data-testid="reset-button"
            onClick={this.resetError}
          >
            重试
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

export default ErrorBoundary;

// 有问题的组件
const BuggyComponent = ({ shouldThrow = false }) => {
  if (shouldThrow) {
    throw new Error('测试错误');
  }

  return <div data-testid="safe-content">正常内容</div>;
};
错误边界测试
// ErrorBoundary.test.js
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import ErrorBoundary from './ErrorBoundary';
import BuggyComponent from './BuggyComponent';

// 抑制控制台错误输出
beforeAll(() => {
  jest.spyOn(console, 'error').mockImplementation(() => {});
});

afterAll(() => {
  console.error.mockRestore();
});

describe('ErrorBoundary组件', () => {
  test('正常渲染子组件', () => {
    render(
      <ErrorBoundary>
        <div data-testid="child">子组件</div>
      </ErrorBoundary>
    );

    expect(screen.getByTestId('child')).toBeInTheDocument();
    expect(screen.queryByTestId('error-boundary')).not.toBeInTheDocument();
  });

  test('捕获子组件错误并显示降级UI', () => {
    render(
      <ErrorBoundary>
        <BuggyComponent shouldThrow={true} />
      </ErrorBoundary>
    );

    expect(screen.getByTestId('error-boundary')).toBeInTheDocument();
    expect(screen.getByTestId('error-message')).toHaveTextContent(
      'Error: 测试错误'
    );
    expect(screen.getByTestId('reset-button')).toBeInTheDocument();
  });

  test('点击重试按钮重置错误状态', () => {
    const { rerender } = render(
      <ErrorBoundary>
        <BuggyComponent shouldThrow={true} />
      </ErrorBoundary>
    );

    // 确认错误显示
    expect(screen.getByTestId('error-boundary')).toBeInTheDocument();

    // 点击重试按钮
    fireEvent.click(screen.getByTestId('reset-button'));

    // 重新渲染不抛出错误的组件
    rerender(
      <ErrorBoundary>
        <BuggyComponent shouldThrow={false} />
      </ErrorBoundary>
    );

    expect(screen.queryByTestId('error-boundary')).not.toBeInTheDocument();
    expect(screen.getByTestId('safe-content')).toBeInTheDocument();
  });

  test('组件树深层的错误被捕获', () => {
    const DeepBuggyComponent = () => (
      <div>
        <div>外层</div>
        <BuggyComponent shouldThrow={true} />
      </div>
    );

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

    expect(screen.getByTestId('error-boundary')).toBeInTheDocument();
  });
});

端到端测试 (Cypress)

// cypress/integration/login.spec.js
describe('登录流程', () => {
  beforeEach(() => {
    // 每次测试前访问登录页
    cy.visit('/login');
  });

  it('成功登录', () => {
    // 输入用户名和密码
    cy.get('[data-testid=email-input]').type('user@example.com');
    cy.get('[data-testid=password-input]').type('password123');

    // 点击登录按钮
    cy.get('[data-testid=submit-button]').click();

    // 验证跳转到首页
    cy.url().should('include', '/dashboard');

    // 验证用户信息显示
    cy.get('[data-testid=welcome-message]').should(
      'contain', '欢迎 user@example.com'
    );
  });

  it('显示验证错误', () => {
    // 不输入密码直接提交
    cy.get('[data-testid=email-input]').type('user@example.com');
    cy.get('[data-testid=submit-button]').click();

    // 验证错误信息显示
    cy.get('[data-testid=password-error]').should(
      'contain', '密码不能为空'
    );

    // 验证没有跳转
    cy.url().should('include', '/login');
  });

  it('记住登录状态', () => {
    // 登录并勾选"记住我"
    cy.get('[data-testid=email-input]').type('user@example.com');
    cy.get('[data-testid=password-input]').type('password123');
    cy.get('[data-testid=remember-me]').check();
    cy.get('[data-testid=submit-button]').click();

    // 刷新页面
    cy.reload();

    // 验证仍然保持登录状态
    cy.url().should('include', '/dashboard');
  });

  it('网络错误处理', () => {
    // 模拟网络错误
    cy.intercept('POST', '/api/login', {
      statusCode: 500,
      body: { error: '服务器错误' }
    });

    cy.get('[data-testid=email-input]').type('user@example.com');
    cy.get('[data-testid=password-input]').type('password123');
    cy.get('[data-testid=submit-button]').click();

    // 验证错误提示
    cy.get('[data-testid=network-error]').should(
      'contain', '登录失败,请重试'
    );
  });
});

// cypress/support/commands.js
Cypress.Commands.add('login', (email, password) => {
  cy.visit('/login');
  cy.get('[data-testid=email-input]').type(email);
  cy.get('[data-testid=password-input]').type(password);
  cy.get('[data-testid=submit-button]').click();
  cy.url().should('include', '/dashboard');
});

Cypress.Commands.add('logout', () => {
  cy.get('[data-testid=logout-button]').click();
  cy.url().should('include', '/login');
});

// 使用自定义命令
describe('使用自定义命令', () => {
  it('登录后执行操作', () => {
    cy.login('user@example.com', 'password123');

    // 登录后的操作
    cy.get('[data-testid=dashboard-title]').should('be.visible');

    cy.logout();
    cy.url().should('include', '/login');
  });
});

测试最佳实践

测试最佳实践检查表

测试指标与覆盖率

测试通过率

95%

目标: > 90%
代码覆盖率

85%

目标: > 80%
测试执行时间

45s

目标: < 60s
测试数量

247

持续增加
常见测试陷阱:
  • 过度测试实现细节:测试用户可见的行为而不是内部实现
  • 脆弱的测试:避免依赖不稳定的CSS选择器或实现细节
  • 忽略异步测试:正确处理异步操作和等待
  • 测试重复逻辑:DRY原则也适用于测试代码
  • 忽略测试维护:定期重构和更新测试代码

测试代码示例