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