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