Jest React 测试 #

React 测试概述 #

React 组件测试主要关注组件的渲染输出和用户交互,使用 Testing Library 的"以用户为中心"的测试方法。

text
┌─────────────────────────────────────────────────────────────┐
│                    React 测试层次                            │
├─────────────────────────────────────────────────────────────┤
│  1. 渲染测试 - 组件是否正确渲染                              │
│  2. 交互测试 - 用户交互是否正常工作                          │
│  3. 状态测试 - 状态变化是否正确                              │
│  4. Props 测试 - Props 传递是否正确                          │
│  5. 集成测试 - 组件协作是否正常                              │
└─────────────────────────────────────────────────────────────┘

环境配置 #

安装依赖 #

bash
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Jest 配置 #

javascript
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|less|scss|sass)$': 'identity-obj-proxy',
  },
};

// jest.setup.js
import '@testing-library/jest-dom';

基础组件测试 #

简单组件 #

jsx
// Greeting.jsx
function Greeting({ name }) {
  return <h1>Hello, {name}!</h1>;
}

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

test('renders greeting with name', () => {
  render(<Greeting name="John" />);
  expect(screen.getByText('Hello, John!')).toBeInTheDocument();
});

test('renders with different names', () => {
  const { rerender } = render(<Greeting name="John" />);
  expect(screen.getByText('Hello, John!')).toBeInTheDocument();

  rerender(<Greeting name="Jane" />);
  expect(screen.getByText('Hello, Jane!')).toBeInTheDocument();
});

条件渲染 #

jsx
// UserStatus.jsx
function UserStatus({ isLoggedIn, user }) {
  if (!isLoggedIn) {
    return <button>Login</button>;
  }
  return <span>Welcome, {user.name}</span>;
}

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

describe('UserStatus', () => {
  test('shows login button when not logged in', () => {
    render(<UserStatus isLoggedIn={false} />);
    expect(screen.getByRole('button', { name: 'Login' })).toBeInTheDocument();
  });

  test('shows welcome message when logged in', () => {
    render(<UserStatus isLoggedIn={true} user={{ name: 'John' }} />);
    expect(screen.getByText('Welcome, John')).toBeInTheDocument();
  });
});

列表渲染 #

jsx
// TodoList.jsx
function TodoList({ items }) {
  if (items.length === 0) {
    return <p>No items</p>;
  }
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.text}</li>
      ))}
    </ul>
  );
}

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

describe('TodoList', () => {
  test('shows empty state', () => {
    render(<TodoList items={[]} />);
    expect(screen.getByText('No items')).toBeInTheDocument();
  });

  test('renders list items', () => {
    const items = [
      { id: 1, text: 'Buy milk' },
      { id: 2, text: 'Walk dog' },
    ];
    render(<TodoList items={items} />);
    
    expect(screen.getAllByRole('listitem')).toHaveLength(2);
    expect(screen.getByText('Buy milk')).toBeInTheDocument();
    expect(screen.getByText('Walk dog')).toBeInTheDocument();
  });
});

用户交互测试 #

按钮点击 #

jsx
// Counter.jsx
import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <span data-testid="count">{count}</span>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
      <button onClick={() => setCount(c => c - 1)}>Decrement</button>
    </div>
  );
}

// Counter.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Counter from './Counter';

describe('Counter', () => {
  test('increments count', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole('button', { name: 'Increment' }));
    expect(screen.getByTestId('count')).toHaveTextContent('1');
  });

  test('decrements count', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole('button', { name: 'Decrement' }));
    expect(screen.getByTestId('count')).toHaveTextContent('-1');
  });

  test('multiple clicks', async () => {
    const user = userEvent.setup();
    render(<Counter />);

    await user.click(screen.getByRole('button', { name: 'Increment' }));
    await user.click(screen.getByRole('button', { name: 'Increment' }));
    await user.click(screen.getByRole('button', { name: 'Increment' }));

    expect(screen.getByTestId('count')).toHaveTextContent('3');
  });
});

表单输入 #

jsx
// SearchForm.jsx
function SearchForm({ onSearch }) {
  const [query, setQuery] = useState('');

  const handleSubmit = (e) => {
    e.preventDefault();
    onSearch(query);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      <button type="submit">Search</button>
    </form>
  );
}

// SearchForm.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SearchForm from './SearchForm';

describe('SearchForm', () => {
  test('updates input value', async () => {
    const user = userEvent.setup();
    render(<SearchForm onSearch={() => {}} />);

    const input = screen.getByPlaceholderText('Search...');
    await user.type(input, 'react testing');

    expect(input).toHaveValue('react testing');
  });

  test('submits form with query', async () => {
    const user = userEvent.setup();
    const onSearch = jest.fn();
    render(<SearchForm onSearch={onSearch} />);

    await user.type(screen.getByPlaceholderText('Search...'), 'react');
    await user.click(screen.getByRole('button', { name: 'Search' }));

    expect(onSearch).toHaveBeenCalledWith('react');
  });
});

复选框和单选框 #

jsx
// Preferences.jsx
function Preferences({ preferences, onChange }) {
  return (
    <div>
      <label>
        <input
          type="checkbox"
          checked={preferences.notifications}
          onChange={(e) => onChange({ ...preferences, notifications: e.target.checked })}
        />
        Enable notifications
      </label>
      <label>
        <input
          type="radio"
          name="theme"
          value="light"
          checked={preferences.theme === 'light'}
          onChange={() => onChange({ ...preferences, theme: 'light' })}
        />
        Light
      </label>
      <label>
        <input
          type="radio"
          name="theme"
          value="dark"
          checked={preferences.theme === 'dark'}
          onChange={() => onChange({ ...preferences, theme: 'dark' })}
        />
        Dark
      </label>
    </div>
  );
}

// Preferences.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Preferences from './Preferences';

describe('Preferences', () => {
  test('toggles checkbox', async () => {
    const user = userEvent.setup();
    const onChange = jest.fn();
    render(<Preferences preferences={{ notifications: false, theme: 'light' }} onChange={onChange} />);

    await user.click(screen.getByLabelText('Enable notifications'));

    expect(onChange).toHaveBeenCalledWith({ notifications: true, theme: 'light' });
  });

  test('selects radio button', async () => {
    const user = userEvent.setup();
    const onChange = jest.fn();
    render(<Preferences preferences={{ notifications: false, theme: 'light' }} onChange={onChange} />);

    await user.click(screen.getByLabelText('Dark'));

    expect(onChange).toHaveBeenCalledWith({ notifications: false, theme: 'dark' });
  });
});

异步操作测试 #

数据获取 #

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

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

  useEffect(() => {
    async function fetchUser() {
      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch');
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    fetchUser();
  }, [userId]);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  return <div>{user.name}</div>;
}

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

describe('UserProfile', () => {
  beforeEach(() => {
    jest.spyOn(global, 'fetch');
  });

  afterEach(() => {
    jest.restoreAllMocks();
  });

  test('shows loading state', () => {
    fetch.mockImplementation(() => new Promise(() => {}));
    render(<UserProfile userId={1} />);
    expect(screen.getByText('Loading...')).toBeInTheDocument();
  });

  test('shows user data', async () => {
    fetch.mockResolvedValue({
      ok: true,
      json: () => Promise.resolve({ name: 'John' }),
    });
    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('John')).toBeInTheDocument();
    });
  });

  test('shows error message', async () => {
    fetch.mockResolvedValue({ ok: false });
    render(<UserProfile userId={1} />);

    await waitFor(() => {
      expect(screen.getByText('Error: Failed to fetch')).toBeInTheDocument();
    });
  });
});

Hooks 测试 #

自定义 Hook 测试 #

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

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

// 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());

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

    expect(result.current.count).toBe(-1);
  });

  test('resets count', () => {
    const { result } = renderHook(() => useCounter(5));

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

    expect(result.current.count).toBe(5);
  });
});

测试 useEffect #

jsx
// useDocumentTitle.js
import { useEffect } from 'react';

function useDocumentTitle(title) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}

// useDocumentTitle.test.js
import { renderHook } from '@testing-library/react';
import useDocumentTitle from './useDocumentTitle';

test('updates document title', () => {
  renderHook(() => useDocumentTitle('Test Title'));
  expect(document.title).toBe('Test Title');
});

test('updates title on change', () => {
  const { rerender } = renderHook(({ title }) => useDocumentTitle(title), {
    initialProps: { title: 'First Title' },
  });

  expect(document.title).toBe('First Title');

  rerender({ title: 'Second Title' });
  expect(document.title).toBe('Second Title');
});

Context 测试 #

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

const ThemeContext = createContext();

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

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

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

function useTheme() {
  return useContext(ThemeContext);
}

// ThemeContext.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ThemeProvider, useTheme } from './ThemeContext';

function TestComponent() {
  const { theme, toggleTheme } = useTheme();
  return (
    <div>
      <span data-testid="theme">{theme}</span>
      <button onClick={toggleTheme}>Toggle</button>
    </div>
  );
}

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

    expect(screen.getByTestId('theme')).toHaveTextContent('light');
  });

  test('toggles theme', async () => {
    const user = userEvent.setup();
    render(
      <ThemeProvider>
        <TestComponent />
      </ThemeProvider>
    );

    await user.click(screen.getByRole('button', { name: 'Toggle' }));
    expect(screen.getByTestId('theme')).toHaveTextContent('dark');

    await user.click(screen.getByRole('button', { name: 'Toggle' }));
    expect(screen.getByTestId('theme')).toHaveTextContent('light');
  });
});

最佳实践 #

1. 测试用户行为 #

javascript
// ❌ 不好的做法 - 测试实现细节
expect(component.state.count).toBe(1);

// ✅ 好的做法 - 测试用户看到的内容
expect(screen.getByText('Count: 1')).toBeInTheDocument();

2. 使用可访问性查询 #

javascript
// ❌ 不好的做法
screen.getByTestId('submit-button');

// ✅ 好的做法
screen.getByRole('button', { name: 'Submit' });

3. 测试异步操作 #

javascript
// ❌ 不好的做法
test('bad async', () => {
  render(<Component />);
  expect(screen.getByText('Loaded')).toBeInTheDocument();
});

// ✅ 好的做法
test('good async', async () => {
  render(<Component />);
  await screen.findByText('Loaded');
});

下一步 #

现在你已经掌握了 React 组件测试,接下来学习 Vue 测试 实战 Vue 组件测试!

最后更新:2026-03-28