Enzyme 生命周期测试 #
React 生命周期概述 #
React 组件经历三个主要阶段:挂载、更新和卸载。了解这些阶段对于编写有效的测试至关重要。
生命周期阶段 #
text
┌─────────────────────────────────────────────────────────────┐
│ React 生命周期阶段 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 挂载阶段 (Mounting) │
│ ├── constructor() │
│ ├── static getDerivedStateFromProps() │
│ ├── render() │
│ ├── componentDidMount() │
│ │ │
│ 更新阶段 (Updating) │
│ ├── static getDerivedStateFromProps() │
│ ├── shouldComponentUpdate() │
│ ├── render() │
│ ├── getSnapshotBeforeUpdate() │
│ ├── componentDidUpdate() │
│ │ │
│ 卸载阶段 (Unmounting) │
│ └── componentWillUnmount() │
│ │
└─────────────────────────────────────────────────────────────┘
挂载阶段测试 #
componentDidMount 测试 #
javascript
class DataFetcher extends React.Component {
constructor(props) {
super(props);
this.state = { data: null, loading: true };
}
componentDidMount() {
this.props.fetchData().then(data => {
this.setState({ data, loading: false });
});
}
render() {
const { data, loading } = this.state;
if (loading) return <div>Loading...</div>;
return <div>{data}</div>;
}
}
describe('DataFetcher componentDidMount', () => {
it('calls fetchData on mount', () => {
const mockFetch = jest.fn().mockResolvedValue('test data');
const wrapper = mount(
<DataFetcher fetchData={mockFetch} />
);
expect(mockFetch).toHaveBeenCalled();
wrapper.unmount();
});
it('shows loading state initially', () => {
const mockFetch = jest.fn().mockResolvedValue('test data');
const wrapper = mount(
<DataFetcher fetchData={mockFetch} />
);
expect(wrapper.text()).toBe('Loading...');
wrapper.unmount();
});
it('displays data after fetch', async () => {
const mockFetch = jest.fn().mockResolvedValue('test data');
const wrapper = mount(
<DataFetcher fetchData={mockFetch} />
);
await new Promise(resolve => setTimeout(resolve, 0));
wrapper.update();
expect(wrapper.text()).toBe('test data');
wrapper.unmount();
});
});
constructor 测试 #
javascript
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = {
count: props.initialCount || 0,
step: props.step || 1
};
}
render() {
return (
<div>
<span>{this.state.count}</span>
<span>{this.state.step}</span>
</div>
);
}
}
describe('Counter constructor', () => {
it('initializes state with props', () => {
const wrapper = shallow(<Counter initialCount={10} step={2} />);
expect(wrapper.state('count')).toBe(10);
expect(wrapper.state('step')).toBe(2);
});
it('uses default values when props not provided', () => {
const wrapper = shallow(<Counter />);
expect(wrapper.state('count')).toBe(0);
expect(wrapper.state('step')).toBe(1);
});
});
更新阶段测试 #
componentDidUpdate 测试 #
javascript
class UserProfile extends React.Component {
constructor(props) {
super(props);
this.state = { displayname: props.user.name };
}
componentDidUpdate(prevProps) {
if (prevProps.user.id !== this.props.user.id) {
this.setState({ displayname: this.props.user.name });
}
}
render() {
return <div>{this.state.displayname}</div>;
}
}
describe('UserProfile componentDidUpdate', () => {
it('updates displayname when user changes', () => {
const wrapper = mount(
<UserProfile user={{ id: 1, name: 'John' }} />
);
expect(wrapper.text()).toBe('John');
wrapper.setProps({ user: { id: 2, name: 'Jane' } });
expect(wrapper.text()).toBe('Jane');
wrapper.unmount();
});
it('does not update when same user', () => {
const wrapper = mount(
<UserProfile user={{ id: 1, name: 'John' }} />
);
wrapper.setProps({ user: { id: 1, name: 'John' } });
expect(wrapper.text()).toBe('John');
wrapper.unmount();
});
});
shouldComponentUpdate 测试 #
javascript
class OptimizedList extends React.Component {
constructor(props) {
super(props);
this.renderCount = 0;
}
shouldComponentUpdate(nextProps) {
return nextProps.items.length !== this.props.items.length;
}
render() {
this.renderCount++;
return (
<div>
<span className="count">{this.props.items.length}</span>
<span className="renders">{this.renderCount}</span>
</div>
);
}
}
describe('OptimizedList shouldComponentUpdate', () => {
it('re-renders when items length changes', () => {
const wrapper = shallow(<OptimizedList items={[1, 2, 3]} />);
expect(wrapper.find('.renders').text()).toBe('1');
wrapper.setProps({ items: [1, 2, 3, 4] });
expect(wrapper.find('.renders').text()).toBe('2');
});
it('does not re-render when items length is same', () => {
const wrapper = shallow(<OptimizedList items={[1, 2, 3]} />);
expect(wrapper.find('.renders').text()).toBe('1');
wrapper.setProps({ items: [4, 5, 6] });
expect(wrapper.find('.renders').text()).toBe('1');
});
});
卸载阶段测试 #
componentWillUnmount 测试 #
javascript
class Timer extends React.Component {
constructor(props) {
super(props);
this.state = { seconds: 0 };
this.interval = null;
}
componentDidMount() {
this.interval = setInterval(() => {
this.setState(prev => ({ seconds: prev.seconds + 1 }));
}, 1000);
}
componentWillUnmount() {
clearInterval(this.interval);
this.props.onUnmount();
}
render() {
return <div>{this.state.seconds}</div>;
}
}
describe('Timer componentWillUnmount', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('clears interval on unmount', () => {
const mockUnmount = jest.fn();
const wrapper = mount(<Timer onUnmount={mockUnmount} />);
wrapper.unmount();
expect(mockUnmount).toHaveBeenCalled();
});
it('stops updating after unmount', () => {
const mockUnmount = jest.fn();
const wrapper = mount(<Timer onUnmount={mockUnmount} />);
expect(wrapper.text()).toBe('0');
jest.advanceTimersByTime(1000);
wrapper.update();
expect(wrapper.text()).toBe('1');
wrapper.unmount();
jest.advanceTimersByTime(1000);
expect(mockUnmount).toHaveBeenCalled();
});
});
清理副作用测试 #
javascript
class EventListener extends React.Component {
constructor(props) {
super(props);
this.state = { width: window.innerWidth };
this.handleResize = this.handleResize.bind(this);
}
componentDidMount() {
window.addEventListener('resize', this.handleResize);
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
handleResize() {
this.setState({ width: window.innerWidth });
}
render() {
return <div>{this.state.width}</div>;
}
}
describe('EventListener cleanup', () => {
it('removes event listener on unmount', () => {
const addSpy = jest.spyOn(window, 'addEventListener');
const removeSpy = jest.spyOn(window, 'removeEventListener');
const wrapper = mount(<EventListener />);
expect(addSpy).toHaveBeenCalledWith('resize', expect.any(Function));
wrapper.unmount();
expect(removeSpy).toHaveBeenCalledWith('resize', expect.any(Function));
addSpy.mockRestore();
removeSpy.mockRestore();
});
});
useEffect 测试 #
基本 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('fetches data on mount', async () => {
const mockFetch = jest.fn().mockResolvedValue('test data');
const wrapper = mount(<DataFetcher fetchData={mockFetch} />);
expect(mockFetch).toHaveBeenCalled();
expect(wrapper.text()).toBe('Loading...');
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
wrapper.update();
expect(wrapper.text()).toBe('test data');
wrapper.unmount();
});
});
依赖数组测试 #
javascript
function Counter({ step }) {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`Step changed to ${step}`);
}, [step]);
return (
<div>
<span>{count}</span>
<button onClick={() => setCount(count + step)}>Add</button>
</div>
);
}
describe('Counter useEffect dependencies', () => {
it('runs effect when dependency changes', () => {
const consoleSpy = jest.spyOn(console, 'log').mockImplementation();
const wrapper = mount(<Counter step={1} />);
expect(consoleSpy).toHaveBeenCalledWith('Step changed to 1');
consoleSpy.mockClear();
wrapper.setProps({ step: 2 });
expect(consoleSpy).toHaveBeenCalledWith('Step changed to 2');
consoleSpy.mockRestore();
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('clears interval on unmount', () => {
const wrapper = mount(<Timer />);
expect(wrapper.text()).toBe('0');
jest.advanceTimersByTime(1000);
wrapper.update();
expect(wrapper.text()).toBe('1');
wrapper.unmount();
jest.advanceTimersByTime(5000);
expect(wrapper.exists()).toBe(false);
});
});
完整生命周期示例 #
订阅组件测试 #
javascript
class SubscriptionComponent extends React.Component {
constructor(props) {
super(props);
this.state = {
messages: [],
connected: false
};
this.subscription = null;
}
componentDidMount() {
this.subscribe();
}
componentDidUpdate(prevProps) {
if (prevProps.channelId !== this.props.channelId) {
this.unsubscribe();
this.subscribe();
}
}
componentWillUnmount() {
this.unsubscribe();
}
subscribe() {
this.setState({ connected: false });
this.subscription = this.props.subscribe(this.props.channelId, (message) => {
this.setState(prev => ({
messages: [...prev.messages, message]
}));
});
this.setState({ connected: true });
}
unsubscribe() {
if (this.subscription) {
this.subscription.unsubscribe();
this.subscription = null;
}
this.setState({ connected: false, messages: [] });
}
render() {
const { messages, connected } = this.state;
return (
<div>
<span className="status">
{connected ? 'Connected' : 'Disconnected'}
</span>
<ul className="messages">
{messages.map((msg, i) => (
<li key={i}>{msg}</li>
))}
</ul>
</div>
);
}
}
describe('SubscriptionComponent lifecycle', () => {
let mockSubscribe;
let mockUnsubscribe;
beforeEach(() => {
mockUnsubscribe = jest.fn();
mockSubscribe = jest.fn((channelId, callback) => {
return {
unsubscribe: mockUnsubscribe,
channelId,
callback
};
});
});
it('subscribes on mount', () => {
const wrapper = mount(
<SubscriptionComponent
channelId="ch-1"
subscribe={mockSubscribe}
/>
);
expect(mockSubscribe).toHaveBeenCalledWith('ch-1', expect.any(Function));
expect(wrapper.state('connected')).toBe(true);
wrapper.unmount();
});
it('receives messages through subscription', () => {
const wrapper = mount(
<SubscriptionComponent
channelId="ch-1"
subscribe={mockSubscribe}
/>
);
const subscription = mockSubscribe.mock.results[0].value;
subscription.callback('Hello');
subscription.callback('World');
wrapper.update();
expect(wrapper.find('.messages li').length).toBe(2);
expect(wrapper.find('.messages li').at(0).text()).toBe('Hello');
expect(wrapper.find('.messages li').at(1).text()).toBe('World');
wrapper.unmount();
});
it('resubscribes when channel changes', () => {
const wrapper = mount(
<SubscriptionComponent
channelId="ch-1"
subscribe={mockSubscribe}
/>
);
expect(mockSubscribe).toHaveBeenCalledTimes(1);
wrapper.setProps({ channelId: 'ch-2' });
expect(mockUnsubscribe).toHaveBeenCalled();
expect(mockSubscribe).toHaveBeenCalledTimes(2);
expect(mockSubscribe).toHaveBeenLastCalledWith('ch-2', expect.any(Function));
wrapper.unmount();
});
it('unsubscribes on unmount', () => {
const wrapper = mount(
<SubscriptionComponent
channelId="ch-1"
subscribe={mockSubscribe}
/>
);
wrapper.unmount();
expect(mockUnsubscribe).toHaveBeenCalled();
});
});
禁用生命周期方法 #
使用 disableLifecycleMethods #
javascript
describe('Disable lifecycle', () => {
it('can disable componentDidMount', () => {
const mockMount = jest.fn();
class Component extends React.Component {
componentDidMount() {
mockMount();
}
render() {
return <div />;
}
}
const wrapper = shallow(<Component />, {
disableLifecycleMethods: true
});
expect(mockMount).not.toHaveBeenCalled();
// 手动触发生命周期
wrapper.instance().componentDidMount();
expect(mockMount).toHaveBeenCalled();
});
});
最佳实践 #
1. 使用 beforeEach/afterEach 清理 #
javascript
describe('Lifecycle tests', () => {
let wrapper;
afterEach(() => {
if (wrapper) {
wrapper.unmount();
wrapper = null;
}
});
it('test case 1', () => {
wrapper = mount(<Component />);
// 测试代码
});
it('test case 2', () => {
wrapper = mount(<Component />);
// 测试代码
});
});
2. 使用 Jest 定时器模拟 #
javascript
describe('Timer tests', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('handles timers correctly', () => {
const wrapper = mount(<Timer />);
jest.advanceTimersByTime(1000);
wrapper.update();
expect(wrapper.text()).toBe('1');
wrapper.unmount();
});
});
3. 测试副作用清理 #
javascript
it('cleans up resources', () => {
const cleanup = jest.fn();
function Component() {
useEffect(() => {
return cleanup;
}, []);
return null;
}
const wrapper = mount(<Component />);
wrapper.unmount();
expect(cleanup).toHaveBeenCalled();
});
下一步 #
现在你已经掌握了生命周期测试的方法,接下来学习 Hooks 测试 了解更多 React Hooks 的测试技巧!
最后更新:2026-03-28