Enzyme Hooks 测试 #

Hooks 测试概述 #

React Hooks 改变了我们编写组件的方式,也影响了测试策略。Enzyme 可以测试使用 Hooks 的函数组件,但需要注意一些特殊技巧。

Hooks 类型 #

text
┌─────────────────────────────────────────────────────────────┐
│                    React Hooks 分类                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  基础 Hooks                                                  │
│  ├── useState      状态管理                                 │
│  ├── useEffect     副作用处理                               │
│  └── useContext    上下文访问                               │
│                                                             │
│  额外 Hooks                                                  │
│  ├── useReducer    复杂状态管理                             │
│  ├── useCallback   函数缓存                                 │
│  ├── useMemo       值缓存                                   │
│  ├── useRef        DOM/值引用                               │
│  └── useImperativeHandle  暴露实例方法                       │
│                                                             │
│  自定义 Hooks                                                │
│  └── useCustomHook 自定义逻辑封装                            │
│                                                             │
└─────────────────────────────────────────────────────────────┘

useState 测试 #

基本状态测试 #

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

describe('Counter with useState', () => {
  it('renders initial count', () => {
    const wrapper = shallow(<Counter />);
    expect(wrapper.find('[data-testid="count"]').text()).toBe('0');
  });
  
  it('increments count', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('button').at(0).simulate('click');
    
    expect(wrapper.find('[data-testid="count"]').text()).toBe('1');
  });
  
  it('decrements count', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('button').at(1).simulate('click');
    
    expect(wrapper.find('[data-testid="count"]').text()).toBe('-1');
  });
  
  it('handles multiple increments', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('button').at(0).simulate('click');
    wrapper.find('button').at(0).simulate('click');
    wrapper.find('button').at(0).simulate('click');
    
    expect(wrapper.find('[data-testid="count"]').text()).toBe('3');
  });
});

复杂状态测试 #

javascript
function TodoList() {
  const [todos, setTodos] = useState([]);
  const [input, setInput] = useState('');
  
  const addTodo = () => {
    if (input.trim()) {
      setTodos([...todos, { id: Date.now(), text: input, done: false }]);
      setInput('');
    }
  };
  
  const toggleTodo = (id) => {
    setTodos(todos.map(todo =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  };
  
  const deleteTodo = (id) => {
    setTodos(todos.filter(todo => todo.id !== id));
  };
  
  return (
    <div>
      <input 
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="Add todo"
      />
      <button onClick={addTodo}>Add</button>
      <ul>
        {todos.map(todo => (
          <li key={todo.id} className={todo.done ? 'done' : ''}>
            <span onClick={() => toggleTodo(todo.id)}>{todo.text}</span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

describe('TodoList with useState', () => {
  it('starts with empty list', () => {
    const wrapper = shallow(<TodoList />);
    expect(wrapper.find('li').length).toBe(0);
  });
  
  it('adds a todo', () => {
    const wrapper = shallow(<TodoList />);
    
    wrapper.find('input').simulate('change', { target: { value: 'Test todo' } });
    wrapper.find('button').at(0).simulate('click');
    
    expect(wrapper.find('li').length).toBe(1);
    expect(wrapper.find('li span').text()).toBe('Test todo');
  });
  
  it('clears input after adding', () => {
    const wrapper = shallow(<TodoList />);
    
    wrapper.find('input').simulate('change', { target: { value: 'Test' } });
    wrapper.find('button').at(0).simulate('click');
    
    expect(wrapper.find('input').prop('value')).toBe('');
  });
  
  it('toggles todo', () => {
    const wrapper = shallow(<TodoList />);
    
    wrapper.find('input').simulate('change', { target: { value: 'Test' } });
    wrapper.find('button').at(0).simulate('click');
    
    expect(wrapper.find('li').hasClass('done')).toBe(false);
    
    wrapper.find('li span').simulate('click');
    
    expect(wrapper.find('li').hasClass('done')).toBe(true);
  });
  
  it('deletes todo', () => {
    const wrapper = shallow(<TodoList />);
    
    wrapper.find('input').simulate('change', { target: { value: 'Test' } });
    wrapper.find('button').at(0).simulate('click');
    
    expect(wrapper.find('li').length).toBe(1);
    
    wrapper.find('li button').simulate('click');
    
    expect(wrapper.find('li').length).toBe(0);
  });
});

useEffect 测试 #

基本副作用测试 #

javascript
function DataFetcher({ fetchData }) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetchData().then(result => {
      setData(result);
      setLoading(false);
    });
  }, [fetchData]);
  
  if (loading) return <div>Loading...</div>;
  return <div>{data}</div>;
}

describe('DataFetcher useEffect', () => {
  it('shows loading initially', () => {
    const mockFetch = jest.fn().mockResolvedValue('data');
    const wrapper = shallow(<DataFetcher fetchData={mockFetch} />);
    
    expect(wrapper.text()).toBe('Loading...');
  });
  
  it('fetches data on mount', async () => {
    const mockFetch = jest.fn().mockResolvedValue('test data');
    const wrapper = mount(<DataFetcher fetchData={mockFetch} />);
    
    await act(async () => {
      await new Promise(resolve => setTimeout(resolve, 0));
    });
    wrapper.update();
    
    expect(mockFetch).toHaveBeenCalled();
    expect(wrapper.text()).toBe('test data');
    
    wrapper.unmount();
  });
});

依赖数组测试 #

javascript
function UserProfile({ userId, fetchUser }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId, fetchUser]);
  
  if (!user) return <div>Loading...</div>;
  return <div>{user.name}</div>;
}

describe('UserProfile useEffect dependencies', () => {
  it('fetches user on mount', async () => {
    const mockFetch = jest.fn().mockResolvedValue({ name: 'John' });
    const wrapper = mount(<UserProfile userId={1} fetchUser={mockFetch} />);
    
    await act(async () => {
      await new Promise(resolve => setTimeout(resolve, 0));
    });
    wrapper.update();
    
    expect(mockFetch).toHaveBeenCalledWith(1);
    
    wrapper.unmount();
  });
  
  it('refetches when userId changes', async () => {
    const mockFetch = jest.fn()
      .mockResolvedValueOnce({ name: 'John' })
      .mockResolvedValueOnce({ name: 'Jane' });
    
    const wrapper = mount(<UserProfile userId={1} fetchUser={mockFetch} />);
    
    await act(async () => {
      await new Promise(resolve => setTimeout(resolve, 0));
    });
    wrapper.update();
    
    expect(mockFetch).toHaveBeenCalledTimes(1);
    
    wrapper.setProps({ userId: 2 });
    
    await act(async () => {
      await new Promise(resolve => setTimeout(resolve, 0));
    });
    wrapper.update();
    
    expect(mockFetch).toHaveBeenCalledTimes(2);
    expect(mockFetch).toHaveBeenNthCalledWith(2, 2);
    
    wrapper.unmount();
  });
});

清理函数测试 #

javascript
function Timer() {
  const [seconds, setSeconds] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setSeconds(s => s + 1);
    }, 1000);
    
    return () => clearInterval(interval);
  }, []);
  
  return <div>{seconds}</div>;
}

describe('Timer cleanup', () => {
  beforeEach(() => {
    jest.useFakeTimers();
  });
  
  afterEach(() => {
    jest.useRealTimers();
  });
  
  it('updates seconds', () => {
    const wrapper = mount(<Timer />);
    
    expect(wrapper.text()).toBe('0');
    
    act(() => {
      jest.advanceTimersByTime(1000);
    });
    wrapper.update();
    
    expect(wrapper.text()).toBe('1');
    
    wrapper.unmount();
  });
  
  it('stops on unmount', () => {
    const wrapper = mount(<Timer />);
    
    act(() => {
      jest.advanceTimersByTime(1000);
    });
    wrapper.update();
    
    expect(wrapper.text()).toBe('1');
    
    wrapper.unmount();
    
    act(() => {
      jest.advanceTimersByTime(5000);
    });
    
    // 组件已卸载,不会再更新
  });
});

useContext 测试 #

基本 Context 测试 #

javascript
const ThemeContext = React.createContext('light');

function ThemedButton() {
  const theme = useContext(ThemeContext);
  
  return (
    <button className={`btn btn-${theme}`}>
      {theme} theme
    </button>
  );
}

describe('ThemedButton useContext', () => {
  it('uses default context value', () => {
    const wrapper = shallow(<ThemedButton />);
    
    expect(wrapper.hasClass('btn-light')).toBe(true);
    expect(wrapper.text()).toBe('light theme');
  });
  
  it('uses provided context value', () => {
    const wrapper = mount(
      <ThemeContext.Provider value="dark">
        <ThemedButton />
      </ThemeContext.Provider>
    );
    
    expect(wrapper.find('button').hasClass('btn-dark')).toBe(true);
    expect(wrapper.find('button').text()).toBe('dark theme');
    
    wrapper.unmount();
  });
});

复杂 Context 测试 #

javascript
const UserContext = React.createContext({
  user: null,
  login: jest.fn(),
  logout: jest.fn()
});

function UserProfile() {
  const { user, login, logout } = useContext(UserContext);
  
  if (!user) {
    return <button onClick={() => login('test')}>Login</button>;
  }
  
  return (
    <div>
      <span>{user.name}</span>
      <button onClick={logout}>Logout</button>
    </div>
  );
}

describe('UserProfile useContext', () => {
  it('shows login button when no user', () => {
    const mockLogin = jest.fn();
    const mockLogout = jest.fn();
    
    const wrapper = mount(
      <UserContext.Provider value={{ user: null, login: mockLogin, logout: mockLogout }}>
        <UserProfile />
      </UserContext.Provider>
    );
    
    expect(wrapper.find('button').text()).toBe('Login');
    
    wrapper.find('button').simulate('click');
    
    expect(mockLogin).toHaveBeenCalledWith('test');
    
    wrapper.unmount();
  });
  
  it('shows user info when logged in', () => {
    const mockLogin = jest.fn();
    const mockLogout = jest.fn();
    
    const wrapper = mount(
      <UserContext.Provider value={{ 
        user: { name: 'John' }, 
        login: mockLogin, 
        logout: mockLogout 
      }}>
        <UserProfile />
      </UserContext.Provider>
    );
    
    expect(wrapper.find('span').text()).toBe('John');
    
    wrapper.find('button').simulate('click');
    
    expect(mockLogout).toHaveBeenCalled();
    
    wrapper.unmount();
  });
});

useRef 测试 #

DOM 引用测试 #

javascript
function InputWithFocus() {
  const inputRef = useRef(null);
  
  const focusInput = () => {
    inputRef.current.focus();
  };
  
  return (
    <div>
      <input ref={inputRef} type="text" />
      <button onClick={focusInput}>Focus</button>
    </div>
  );
}

describe('InputWithFocus useRef', () => {
  it('has ref attached to input', () => {
    const wrapper = mount(<InputWithFocus />);
    
    const input = wrapper.find('input').getDOMNode();
    const focusSpy = jest.spyOn(input, 'focus');
    
    wrapper.find('button').simulate('click');
    
    expect(focusSpy).toHaveBeenCalled();
    
    focusSpy.mockRestore();
    wrapper.unmount();
  });
});

值引用测试 #

javascript
function CounterWithRef() {
  const [count, setCount] = useState(0);
  const prevCountRef = useRef(0);
  
  useEffect(() => {
    prevCountRef.current = count;
  }, [count]);
  
  const prevCount = prevCountRef.current;
  
  return (
    <div>
      <span className="current">{count}</span>
      <span className="previous">{prevCount}</span>
      <button onClick={() => setCount(c => c + 1)}>Increment</button>
    </div>
  );
}

describe('CounterWithRef', () => {
  it('tracks previous value', () => {
    const wrapper = mount(<CounterWithRef />);
    
    expect(wrapper.find('.current').text()).toBe('0');
    expect(wrapper.find('.previous').text()).toBe('0');
    
    wrapper.find('button').simulate('click');
    wrapper.update();
    
    expect(wrapper.find('.current').text()).toBe('1');
    expect(wrapper.find('.previous').text()).toBe('0');
    
    wrapper.find('button').simulate('click');
    wrapper.update();
    
    expect(wrapper.find('.current').text()).toBe('2');
    expect(wrapper.find('.previous').text()).toBe('1');
    
    wrapper.unmount();
  });
});

useMemo 和 useCallback 测试 #

useMemo 测试 #

javascript
function ExpensiveList({ items, filter }) {
  const filteredItems = useMemo(() => {
    console.log('Filtering items...');
    return items.filter(item => item.includes(filter));
  }, [items, filter]);
  
  return (
    <ul>
      {filteredItems.map((item, i) => (
        <li key={i}>{item}</li>
      ))}
    </ul>
  );
}

describe('ExpensiveList useMemo', () => {
  it('filters items correctly', () => {
    const items = ['apple', 'banana', 'apricot', 'cherry'];
    const wrapper = shallow(<ExpensiveList items={items} filter="ap" />);
    
    expect(wrapper.find('li').length).toBe(2);
    expect(wrapper.find('li').at(0).text()).toBe('apple');
    expect(wrapper.find('li').at(1).text()).toBe('apricot');
  });
  
  it('memoizes computation', () => {
    const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
    const items = ['apple', 'banana'];
    
    const wrapper = mount(<ExpensiveList items={items} filter="a" />);
    
    expect(consoleSpy).toHaveBeenCalledTimes(1);
    
    consoleSpy.mockClear();
    
    wrapper.setProps({ items: ['apple', 'banana'] });
    
    expect(consoleSpy).not.toHaveBeenCalled();
    
    consoleSpy.mockClear();
    
    wrapper.setProps({ filter: 'b' });
    
    expect(consoleSpy).toHaveBeenCalledTimes(1);
    
    consoleSpy.mockRestore();
    wrapper.unmount();
  });
});

useCallback 测试 #

javascript
function Parent() {
  const [count, setCount] = useState(0);
  
  const handleClick = useCallback(() => {
    setCount(c => c + 1);
  }, []);
  
  return (
    <div>
      <span>{count}</span>
      <Child onClick={handleClick} />
    </div>
  );
}

const Child = React.memo(function Child({ onClick }) {
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
});

describe('Parent useCallback', () => {
  it('memoizes callback', () => {
    const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
    
    const wrapper = mount(<Parent />);
    
    expect(consoleSpy).toHaveBeenCalledTimes(1);
    
    consoleSpy.mockClear();
    
    wrapper.find('button').simulate('click');
    wrapper.update();
    
    expect(wrapper.find('span').text()).toBe('1');
    expect(consoleSpy).not.toHaveBeenCalled();
    
    consoleSpy.mockRestore();
    wrapper.unmount();
  });
});

useReducer 测试 #

基本 useReducer 测试 #

javascript
const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    case 'reset':
      return initialState;
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  
  return (
    <div>
      <span>{state.count}</span>
      <button onClick={() => dispatch({ type: 'increment' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>-</button>
      <button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
    </div>
  );
}

describe('Counter useReducer', () => {
  it('starts with initial state', () => {
    const wrapper = shallow(<Counter />);
    expect(wrapper.find('span').text()).toBe('0');
  });
  
  it('increments count', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('button').at(0).simulate('click');
    
    expect(wrapper.find('span').text()).toBe('1');
  });
  
  it('decrements count', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('button').at(1).simulate('click');
    
    expect(wrapper.find('span').text()).toBe('-1');
  });
  
  it('resets count', () => {
    const wrapper = shallow(<Counter />);
    
    wrapper.find('button').at(0).simulate('click');
    wrapper.find('button').at(0).simulate('click');
    
    expect(wrapper.find('span').text()).toBe('2');
    
    wrapper.find('button').at(2).simulate('click');
    
    expect(wrapper.find('span').text()).toBe('0');
  });
});

自定义 Hooks 测试 #

测试自定义 Hook #

javascript
function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  
  const increment = useCallback(() => setCount(c => c + 1), []);
  const decrement = useCallback(() => setCount(c => c - 1), []);
  const reset = useCallback(() => setCount(initialValue), [initialValue]);
  
  return { count, increment, decrement, reset };
}

function Counter({ initialValue }) {
  const { count, increment, decrement, reset } = useCounter(initialValue);
  
  return (
    <div>
      <span>{count}</span>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

describe('useCounter hook', () => {
  it('uses initial value', () => {
    const wrapper = shallow(<Counter initialValue={10} />);
    expect(wrapper.find('span').text()).toBe('10');
  });
  
  it('increments', () => {
    const wrapper = shallow(<Counter />);
    wrapper.find('button').at(0).simulate('click');
    expect(wrapper.find('span').text()).toBe('1');
  });
  
  it('decrements', () => {
    const wrapper = shallow(<Counter />);
    wrapper.find('button').at(1).simulate('click');
    expect(wrapper.find('span').text()).toBe('-1');
  });
  
  it('resets to initial value', () => {
    const wrapper = shallow(<Counter initialValue={5} />);
    wrapper.find('button').at(0).simulate('click');
    wrapper.find('button').at(0).simulate('click');
    expect(wrapper.find('span').text()).toBe('7');
    wrapper.find('button').at(2).simulate('click');
    expect(wrapper.find('span').text()).toBe('5');
  });
});

使用 @testing-library/react-hooks #

javascript
import { renderHook, act } from '@testing-library/react-hooks';

describe('useCounter with testing-library', () => {
  it('should initialize count', () => {
    const { result } = renderHook(() => useCounter(10));
    
    expect(result.current.count).toBe(10);
  });
  
  it('should increment count', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.increment();
    });
    
    expect(result.current.count).toBe(1);
  });
  
  it('should decrement count', () => {
    const { result } = renderHook(() => useCounter());
    
    act(() => {
      result.current.decrement();
    });
    
    expect(result.current.count).toBe(-1);
  });
  
  it('should reset count', () => {
    const { result } = renderHook(() => useCounter(5));
    
    act(() => {
      result.current.increment();
      result.current.increment();
    });
    
    expect(result.current.count).toBe(7);
    
    act(() => {
      result.current.reset();
    });
    
    expect(result.current.count).toBe(5);
  });
});

最佳实践 #

1. 使用 act() 包装状态更新 #

javascript
import { act } from 'react-dom/test-utils';

it('handles async updates', async () => {
  const wrapper = mount(<Component />);
  
  await act(async () => {
    await new Promise(resolve => setTimeout(resolve, 0));
  });
  wrapper.update();
  
  // 断言
});

2. 使用 Jest 定时器模拟 #

javascript
beforeEach(() => {
  jest.useFakeTimers();
});

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

it('handles timers', () => {
  const wrapper = mount(<Timer />);
  
  act(() => {
    jest.advanceTimersByTime(1000);
  });
  wrapper.update();
  
  // 断言
});

3. 测试用户可见的行为 #

javascript
// ✅ 好的做法
it('displays updated count after click', () => {
  const wrapper = shallow(<Counter />);
  wrapper.find('button').simulate('click');
  expect(wrapper.find('span').text()).toBe('1');
});

// ❌ 避免 - 测试内部实现
it('updates state', () => {
  const wrapper = shallow(<Counter />);
  wrapper.find('button').simulate('click');
  // useState 的内部状态不应该直接测试
});

下一步 #

现在你已经掌握了 Hooks 测试的方法,接下来学习 Context 测试 了解更多 Context 的测试技巧!

最后更新:2026-03-28