Jest在React项目中的完整使用指南

下面详细介绍Jest在React项目中的各种使用场景和最佳实践。

安装和配置

1. 安装依赖

# 使用Create React App(已包含Jest)
npx create-react-app my-app

# 或在现有项目中安装
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event

2. Jest配置文件

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapping: {
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
    '\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/fileMock.js',
    '^@/(.*)$': '<rootDir>/src/$1' // 路径别名
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
    '!src/index.js',
    '!src/reportWebVitals.js'
  ]
};
// __mocks__/fileMock.js
module.exports = 'test-file-stub';
// src/setupTests.js
import '@testing-library/jest-dom';

// 全局测试配置

基础组件测试

1. 简单组件测试

// components/Button.jsx
import React from 'react';
import './Button.css';

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

export default Button;
// components/Button.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import Button from './Button';

describe('Button Component', () => {
  test('renders with correct text', () => {
    render(<Button>Click Me</Button>);
    expect(screen.getByText('Click Me')).toBeInTheDocument();
  });

  test('applies correct variant class', () => {
    const { rerender } = render(<Button variant="primary">Test</Button>);
    expect(screen.getByTestId('button')).toHaveClass('btn-primary');

    rerender(<Button variant="secondary">Test</Button>);
    expect(screen.getByTestId('button')).toHaveClass('btn-secondary');
  });

  test('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click Me</Button>);
    
    fireEvent.click(screen.getByText('Click Me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

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

2. 表单组件测试

// components/LoginForm.jsx
import React, { useState } from 'react';

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

  const handleSubmit = (e) => {
    e.preventDefault();
    onSubmit({ email, password });
  };

  return (
    <form onSubmit={handleSubmit} data-testid="login-form">
      <div>
        <label htmlFor="email">Email:</label>
        <input
          type="email"
          id="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          data-testid="email-input"
        />
      </div>
      <div>
        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          data-testid="password-input"
        />
      </div>
      <button type="submit" disabled={loading} data-testid="submit-button">
        {loading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
};

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

describe('LoginForm Component', () => {
  const mockOnSubmit = jest.fn();

  beforeEach(() => {
    mockOnSubmit.mockClear();
  });

  test('submits form with correct data', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);

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

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

    expect(mockOnSubmit).toHaveBeenCalledWith({
      email: 'test@example.com',
      password: 'password123'
    });
  });

  test('shows loading state', () => {
    render(<LoginForm onSubmit={mockOnSubmit} loading={true} />);
    
    expect(screen.getByTestId('submit-button')).toBeDisabled();
    expect(screen.getByTestId('submit-button')).toHaveTextContent('Logging in...');
  });

  test('validates required fields', async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={mockOnSubmit} />);

    // 直接提交而不填写字段
    await user.click(screen.getByTestId('submit-button'));

    // 由于HTML5验证,onSubmit不会被调用
    expect(mockOnSubmit).not.toHaveBeenCalled();
  });
});

异步组件测试

1. 数据获取组件

// components/UserList.jsx
import React, { useState, useEffect } from 'react';
import { userApi } from '../api/userApi';

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

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        setLoading(true);
        const data = await userApi.getUsers();
        setUsers(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []);

  if (loading) return <div data-testid="loading">Loading users...</div>;
  if (error) return <div data-testid="error">Error: {error}</div>;

  return (
    <div data-testid="user-list">
      <h2>Users</h2>
      <ul>
        {users.map(user => (
          <li key={user.id} data-testid={`user-${user.id}`}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default UserList;
// components/UserList.test.jsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import UserList from './UserList';
import { userApi } from '../api/userApi';

// Mock API模块
jest.mock('../api/userApi');

describe('UserList Component', () => {
  const mockUsers = [
    { id: 1, name: 'John Doe', email: 'john@example.com' },
    { id: 2, name: 'Jane Smith', email: 'jane@example.com' }
  ];

  beforeEach(() => {
    userApi.getUsers.mockClear();
  });

  test('displays loading state initially', () => {
    userApi.getUsers.mockImplementation(() => new Promise(() => {}));
    
    render(<UserList />);
    expect(screen.getByTestId('loading')).toBeInTheDocument();
  });

  test('displays users when data is fetched successfully', async () => {
    userApi.getUsers.mockResolvedValue(mockUsers);

    render(<UserList />);

    // 等待数据加载完成
    await waitFor(() => {
      expect(screen.getByTestId('user-list')).toBeInTheDocument();
    });

    expect(screen.getByText('John Doe - john@example.com')).toBeInTheDocument();
    expect(screen.getByText('Jane Smith - jane@example.com')).toBeInTheDocument();
    expect(screen.queryByTestId('loading')).not.toBeInTheDocument();
  });

  test('displays error when data fetch fails', async () => {
    userApi.getUsers.mockRejectedValue(new Error('Network error'));

    render(<UserList />);

    await waitFor(() => {
      expect(screen.getByTestId('error')).toBeInTheDocument();
    });

    expect(screen.getByText('Error: Network error')).toBeInTheDocument();
  });
});

Hook测试

1. 自定义Hook测试

// hooks/useLocalStorage.js
import { useState, useEffect } from 'react';

export const useLocalStorage = (key, initialValue) => {
  const [value, setValue] = useState(() => {
    const storedValue = localStorage.getItem(key);
    return storedValue ? JSON.parse(storedValue) : initialValue;
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
};
// hooks/useLocalStorage.test.js
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

// Mock localStorage
const localStorageMock = (() => {
  let store = {};
  return {
    getItem: jest.fn((key) => store[key] || null),
    setItem: jest.fn((key, value) => {
      store[key] = value.toString();
    }),
    clear: jest.fn(() => {
      store = {};
    }),
    removeItem: jest.fn((key) => {
      delete store[key];
    })
  };
})();

Object.defineProperty(window, 'localStorage', {
  value: localStorageMock
});

describe('useLocalStorage Hook', () => {
  beforeEach(() => {
    localStorageMock.clear();
    localStorageMock.getItem.mockClear();
    localStorageMock.setItem.mockClear();
  });

  test('uses initial value when no stored value', () => {
    const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
    
    expect(result.current[0]).toBe('default');
    expect(localStorageMock.getItem).toHaveBeenCalledWith('testKey');
  });

  test('uses stored value when available', () => {
    localStorageMock.getItem.mockReturnValue('"stored value"');
    
    const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
    
    expect(result.current[0]).toBe('stored value');
  });

  test('updates localStorage when value changes', () => {
    const { result } = renderHook(() => useLocalStorage('testKey', 'default'));
    
    act(() => {
      result.current[1]('new value');
    });
    
    expect(localStorageMock.setItem).toHaveBeenCalledWith(
      'testKey',
      '"new value"'
    );
    expect(result.current[0]).toBe('new value');
  });
});

路由组件测试

1. React Router组件测试

// components/Navigation.jsx
import React from 'react';
import { Link, useLocation } from 'react-router-dom';

const Navigation = () => {
  const location = useLocation();

  return (
    <nav data-testid="navigation">
      <Link 
        to="/" 
        className={location.pathname === '/' ? 'active' : ''}
        data-testid="home-link"
      >
        Home
      </Link>
      <Link 
        to="/about" 
        className={location.pathname === '/about' ? 'active' : ''}
        data-testid="about-link"
      >
        About
      </Link>
      <Link 
        to="/contact" 
        className={location.pathname === '/contact' ? 'active' : ''}
        data-testid="contact-link"
      >
        Contact
      </Link>
    </nav>
  );
};

export default Navigation;
// components/Navigation.test.jsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import Navigation from './Navigation';

describe('Navigation Component', () => {
  const renderWithRouter = (initialPath = '/') => {
    return render(
      <MemoryRouter initialEntries={[initialPath]}>
        <Navigation />
        <Routes>
          <Route path="/" element={<div>Home Page</div>} />
          <Route path="/about" element={<div>About Page</div>} />
          <Route path="/contact" element={<div>Contact Page</div>} />
        </Routes>
      </MemoryRouter>
    );
  };

  test('highlights active link for home', () => {
    renderWithRouter('/');
    
    expect(screen.getByTestId('home-link')).toHaveClass('active');
    expect(screen.getByTestId('about-link')).not.toHaveClass('active');
    expect(screen.getByTestId('contact-link')).not.toHaveClass('active');
  });

  test('highlights active link for about', () => {
    renderWithRouter('/about');
    
    expect(screen.getByTestId('home-link')).not.toHaveClass('active');
    expect(screen.getByTestId('about-link')).toHaveClass('active');
    expect(screen.getByTestId('contact-link')).not.toHaveClass('active');
  });

  test('contains all navigation links', () => {
    renderWithRouter();
    
    expect(screen.getByTestId('home-link')).toHaveTextContent('Home');
    expect(screen.getByTestId('about-link')).toHaveTextContent('About');
    expect(screen.getByTestId('contact-link')).toHaveTextContent('Contact');
  });
});

上下文(Context)测试

1. Context Provider测试

// context/ThemeContext.jsx
import React, { createContext, useContext, useState } from 'react';

const ThemeContext = createContext();

export const useTheme = () => {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within a ThemeProvider');
  }
  return context;
};

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

  const toggleTheme = () => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };

  const value = {
    theme,
    toggleTheme,
    isDark: theme === 'dark'
  };

  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
};
// context/ThemeContext.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ThemeProvider, useTheme } from './ThemeContext';

// 测试组件
const TestComponent = () => {
  const { theme, toggleTheme, isDark } = useTheme();
  
  return (
    <div>
      <span data-testid="theme">{theme}</span>
      <span data-testid="is-dark">{isDark.toString()}</span>
      <button onClick={toggleTheme} data-testid="toggle-theme">
        Toggle Theme
      </button>
    </div>
  );
};

describe('ThemeContext', () => {
  test('provides default theme values', () => {
    render(
      <ThemeProvider>
        <TestComponent />
      </ThemeProvider>
    );

    expect(screen.getByTestId('theme')).toHaveTextContent('light');
    expect(screen.getByTestId('is-dark')).toHaveTextContent('false');
  });

  test('toggles theme correctly', () => {
    render(
      <ThemeProvider>
        <TestComponent />
      </ThemeProvider>
    );

    // 初始状态
    expect(screen.getByTestId('theme')).toHaveTextContent('light');

    // 切换主题
    fireEvent.click(screen.getByTestId('toggle-theme'));

    // 验证主题已切换
    expect(screen.getByTestId('theme')).toHaveTextContent('dark');
    expect(screen.getByTestId('is-dark')).toHaveTextContent('true');

    // 再次切换
    fireEvent.click(screen.getByTestId('toggle-theme'));
    expect(screen.getByTestId('theme')).toHaveTextContent('light');
  });
});

集成测试

1. 完整功能测试

// components/TodoApp.jsx
import React, { useState } from 'react';

const TodoApp = () => {
  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 data-testid="todo-app">
      <h1>Todo App</h1>
      <div>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyPress={(e) => e.key === 'Enter' && addTodo()}
          placeholder="Add a new todo"
          data-testid="todo-input"
        />
        <button onClick={addTodo} data-testid="add-todo-button">
          Add Todo
        </button>
      </div>
      <ul data-testid="todo-list">
        {todos.map(todo => (
          <li key={todo.id} data-testid={`todo-${todo.id}`}>
            <span
              style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
              onClick={() => toggleTodo(todo.id)}
              data-testid="todo-text"
            >
              {todo.text}
            </span>
            <button
              onClick={() => deleteTodo(todo.id)}
              data-testid="delete-todo-button"
            >
              Delete
            </button>
          </li>
        ))}
      </ul>
      <div data-testid="todo-count">
        Total: {todos.length} | Completed: {todos.filter(t => t.completed).length}
      </div>
    </div>
  );
};

export default TodoApp;
// components/TodoApp.test.jsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TodoApp from './TodoApp';

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

    // 添加todo
    await user.type(screen.getByTestId('todo-input'), 'Learn React Testing');
    await user.click(screen.getByTestId('add-todo-button'));

    // 验证todo已添加
    expect(screen.getByText('Learn React Testing')).toBeInTheDocument();
    expect(screen.getByTestId('todo-count')).toHaveTextContent('Total: 1 | Completed: 0');

    // 标记为完成
    await user.click(screen.getByTestId('todo-text'));
    expect(screen.getByText('Learn React Testing')).toHaveStyle(
      'text-decoration: line-through'
    );
    expect(screen.getByTestId('todo-count')).toHaveTextContent('Total: 1 | Completed: 1');

    // 删除todo
    await user.click(screen.getByTestId('delete-todo-button'));
    expect(screen.queryByText('Learn React Testing')).not.toBeInTheDocument();
    expect(screen.getByTestId('todo-count')).toHaveTextContent('Total: 0 | Completed: 0');
  });

  test('add multiple todos', async () => {
    const user = userEvent.setup();
    render(<TodoApp />);

    // 添加多个todos
    const todos = ['Todo 1', 'Todo 2', 'Todo 3'];
    
    for (const todoText of todos) {
      await user.type(screen.getByTestId('todo-input'), todoText);
      await user.click(screen.getByTestId('add-todo-button'));
    }

    // 验证所有todos都存在
    for (const todoText of todos) {
      expect(screen.getByText(todoText)).toBeInTheDocument();
    }

    expect(screen.getByTestId('todo-count')).toHaveTextContent('Total: 3 | Completed: 0');
  });
});

测试覆盖率配置

// package.json
{
  "scripts": {
    "test": "jest",
    "test:coverage": "jest --coverage",
    "test:watch": "jest --watch"
  }
}
# 运行测试并生成覆盖率报告
npm run test:coverage

# 查看覆盖率报告
open coverage/lcov-report/index.html

最佳实践

  1. 优先测试用户行为:而不是实现细节
  2. 使用适当的查询方法:优先使用 getByRolegetByLabelText
  3. 避免过度Mock:只Mock外部依赖
  4. 保持测试独立:每个测试应该能够独立运行
  5. 使用描述性测试名称:清晰表达测试意图

通过以上示例和模式,您可以在React项目中有效地使用Jest进行各种类型的测试,确保代码质量和应用稳定性。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐