测试 React Hook 状态变化

2025-06-10

测试 React Hook 状态变化

修改 (2020):我建议切换到 React-testing-library,并改变你的测试方法,测试组件如何随状态变化,而不是状态本身的变化。虽然这种方法有效,但它并非解决问题的理想方案。我最初想出这个解决方案是因为我开始使用类组件进行 React 工作时,能够使用 Enzyme 助手直接测试状态值是否符合我的预期。有了 Hooks,测试方法就改变了,不再理想。我们应该测试整个组件以及组件如何响应状态变化,而不是状态本身发生了变化。

随着 React Hooks 的引入,测试组件状态变化不再像以前那么简单。不过,我们仍然可以直接测试这些状态变化,只需要一点 mocking 即可。🤠

使用类组件测试状态变化

以前,如果您使用 React 类组件,您可以简单地从浅层对象 Enzyme 通过浅层渲染向我们提供的内容中读取和操作组件状态。

import React from 'react';
class TestComponent extends React.PureComponent {
constructor(props) {
super(props);
this.state = { count: 0 };
}
render() {
return (
<h3>{this.state.count}</h3>
<span>
<button id="count-up" type="button" onClick={() => this.setState({ count: this.state.count + 1 })}>
Count Up!
</button>
<button id="count-down" type="button" onClick={() => this.setState({ count: this.state.count - 1 })}>
Count Down!
</button>
<button id="zero-count" type="button" onClick={() => this.setState({ count: 0 })}>Zero</button>
</span>
)
}
}
export default TestComponent;
import React from 'react';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import TestComponent from './ClassComponent';
Enzyme.configure({ adapter: new Adapter() });
describe('<TestComponent />', () => {
let wrapper;
beforeEach(() => {
wrapper = Enzyme.shallow(<TestComponent />);
});
it('has the initial state count of zero', () => {
expect(wrapper.state()).toEqual({ count: 0 });
})
describe('The Count Up Button', () => {
it('increments state count by 1 on click', () => {
wrapper.find('#count-up').props().onClick();
expect(wrapper.state()).toEqual({ count: 1 });
});
});
describe('The Count Down Button', () => {
it('decrements state count by 1 on click', () => {
wrapper.find('#count-down').props().onClick();
expect(wrapper.state()).toEqual({ count: -1 });
});
});
describe('The Count Zero Button', () => {
it('sets state count to 0 on click', () => {
wrapper.setState({ count: 10 });
wrapper.find('#zero-count').props().onClick();
expect(wrapper.state()).toEqual({ count: 0 });
});
});
});
import React from 'react';
class TestComponent extends React.PureComponent {
constructor(props) {
super(props);
this.state = { count: 0 };
}
render() {
return (
<h3>{this.state.count}</h3>
<span>
<button id="count-up" type="button" onClick={() => this.setState({ count: this.state.count + 1 })}>
Count Up!
</button>
<button id="count-down" type="button" onClick={() => this.setState({ count: this.state.count - 1 })}>
Count Down!
</button>
<button id="zero-count" type="button" onClick={() => this.setState({ count: 0 })}>Zero</button>
</span>
)
}
}
export default TestComponent;
import React from 'react';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import TestComponent from './ClassComponent';
Enzyme.configure({ adapter: new Adapter() });
describe('<TestComponent />', () => {
let wrapper;
beforeEach(() => {
wrapper = Enzyme.shallow(<TestComponent />);
});
it('has the initial state count of zero', () => {
expect(wrapper.state()).toEqual({ count: 0 });
})
describe('The Count Up Button', () => {
it('increments state count by 1 on click', () => {
wrapper.find('#count-up').props().onClick();
expect(wrapper.state()).toEqual({ count: 1 });
});
});
describe('The Count Down Button', () => {
it('decrements state count by 1 on click', () => {
wrapper.find('#count-down').props().onClick();
expect(wrapper.state()).toEqual({ count: -1 });
});
});
describe('The Count Zero Button', () => {
it('sets state count to 0 on click', () => {
wrapper.setState({ count: 10 });
wrapper.find('#zero-count').props().onClick();
expect(wrapper.state()).toEqual({ count: 0 });
});
});
});

使用钩子测试状态变化

然而,随着 hooks 的引入,你现在可以通过React.useState为函数式组件赋予状态。这意味着我们的 Enzyme 浅渲染对象将不再有state()方法。

我之前找到的关于这个主题的实现都谈到了测试状态变化的影响。例如,状态更新时,我们测试显示的计数值是否符合预期,或者测试函数是否使用来自状态的正确参数调用,等等。

我认为这是一种完全有效的测试状态变化的方法。然而,它似乎违背了单元测试时应该考虑的隔离原则。

如果我正在测试 onClick 事件,我真正关心的只是它是否调用了 setCount 并传入了它应该传入的变量。我们相信 React 能够正常工作;因此,我的测试不应该依赖于 useState 来更新状态变量并重新渲染组件以进行单元测试。

那么,我们为什么不嘲笑它呢?

import React from 'react';
const TestComponent = () => {
const [count, setCount] = React.useState(0);
return (
<h3>{count}</h3>
<span>
<button id="count-up" type="button" onClick={() => setCount(count + 1)}>Count Up</button>
<button id="count-down" type="button" onClick={() => setCount(count - 1)}>Count Down</button>
<button id="zero-count" type="button" onClick={() => setCount(0)}>Zero</button>
</span>
);
}
export default TestComponent;
import React from 'react';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import TestComponent from './FunctionalComponent';
Enzyme.configure({ adapter: new Adapter() });
describe('<TestComponent />', () => {
let wrapper;
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState')
useStateSpy.mockImplementation((init) => [init, setState]);
beforeEach(() => {
wrapper = Enzyme.shallow(<TestComponent />);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Count Up', () => {
it('calls setCount with count + 1', () => {
wrapper.find('#count-up').props().onClick();
expect(setState).toHaveBeenCalledWith(1);
});
});
describe('Count Down', () => {
it('calls setCount with count - 1', () => {
wrapper.find('#count-down').props().onClick();
expect(setState).toHaveBeenCalledWith(-1);
});
});
describe('Zero', () => {
it('calls setCount with 0', () => {
wrapper.find('#zero-count').props().onClick();
expect(setState).toHaveBeenCalledWith(0);
});
});
});
import React from 'react';
const TestComponent = () => {
const [count, setCount] = React.useState(0);
return (
<h3>{count}</h3>
<span>
<button id="count-up" type="button" onClick={() => setCount(count + 1)}>Count Up</button>
<button id="count-down" type="button" onClick={() => setCount(count - 1)}>Count Down</button>
<button id="zero-count" type="button" onClick={() => setCount(0)}>Zero</button>
</span>
);
}
export default TestComponent;
import React from 'react';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import TestComponent from './FunctionalComponent';
Enzyme.configure({ adapter: new Adapter() });
describe('<TestComponent />', () => {
let wrapper;
const setState = jest.fn();
const useStateSpy = jest.spyOn(React, 'useState')
useStateSpy.mockImplementation((init) => [init, setState]);
beforeEach(() => {
wrapper = Enzyme.shallow(<TestComponent />);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('Count Up', () => {
it('calls setCount with count + 1', () => {
wrapper.find('#count-up').props().onClick();
expect(setState).toHaveBeenCalledWith(1);
});
});
describe('Count Down', () => {
it('calls setCount with count - 1', () => {
wrapper.find('#count-down').props().onClick();
expect(setState).toHaveBeenCalledWith(-1);
});
});
describe('Zero', () => {
it('calls setCount with 0', () => {
wrapper.find('#zero-count').props().onClick();
expect(setState).toHaveBeenCalledWith(0);
});
});
});

结论

在这个实现中,我们模拟了 React.useState 并返回一个数组,其中包含传递给该方法的初始值和一个 jest 模拟函数。这会将状态设置器设置为我们的模拟函数,并允许我们测试它是否被调用并设置了预期的状态值。

太棒了!现在我们不用费力地翻找 props 来检查状态是否设置正确了。👌

鏂囩珷鏉ユ簮锛�https://dev.to/theactualgivens/testing-react-hook-state-changes-2oga
PREV
React Portal 亮了🔥
NEXT
测试是为了未来