enzyme v2.x 到 v3.x 的迁移指南
从 enzyme v2.x 到 v3.x 的更改比之前的重大版本中更重要,因为 enzyme 的内部实现几乎已经完全重写。
这次重写的目标是解决自 enzyme 最初发布以来一直困扰它的许多主要问题。它还同时移除了 enzyme 对 React 内部依赖的大量内容,并使 enzyme 更加“可插入”,为 enzyme 与 Preact 和 Inferno 等“类似 React”的库一起使用铺平了道路。
我们已尽力使 enzyme v3 的 API 与 v2.x 尽可能兼容,但有少数重大更改是我们决定需要有意进行的,以便支持这种新架构,并从长远来看提高库的可用性。
Airbnb 拥有最大的 enzyme 测试套件之一,大约有 30,000 个 enzyme 单元测试。在 Airbnb 的代码库中将 enzyme 升级到 v3.x 后,其中 99.6% 的测试在没有任何修改的情况下都成功了。我们发现大多数中断的测试都很容易修复,而我们发现有些测试实际上依赖于 v2.x 中可以认为是错误的内容,并且中断实际上是需要的。
在本指南中,我们将介绍我们遇到的最常见的几个中断以及如何修复它们。希望这将使您的升级路径变得更加容易。如果您在升级期间发现了一个对您来说似乎没有意义的中断,请随时提交问题。
配置您的适配器
enzyme 现在有一个“适配器”系统。这意味着您现在需要安装 enzyme 以及另一个模块,该模块提供适配器,告诉 enzyme 如何使用您的 React 版本(或您正在使用的任何其他类似 React 的库)。
在撰写本文时,enzyme 发布了对 React 0.13.x、0.14.x、15.x 和 16.x 的“官方支持”适配器。这些适配器是 npm 包,格式为 enzyme-adapter-react-{{version}}
。
在测试中使用 enzyme 之前,你需要使用你想要使用的适配器配置 enzyme。执行此操作的方法是使用 enzyme.configure(...)
。例如,如果你的项目依赖于 React 16,则需要按如下方式配置 enzyme
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
React semver 范围的适配器 npm 包列表如下
enzyme 适配器包 | React semver 兼容性 | ||
---|---|---|---|
enzyme-adapter-react-16 |
^16.4.0-0 |
||
enzyme-adapter-react-16.3 |
~16.3.0-0 |
||
enzyme-adapter-react-16.2 |
~16.2 |
||
enzyme-adapter-react-16.1 |
`~16.0.0-0 \ | \ | ~16.1` |
enzyme-adapter-react-15 |
^15.5.0 |
||
enzyme-adapter-react-15.4 |
15.0.0-0 - 15.4.x |
||
enzyme-adapter-react-14 |
^0.14.0 |
||
enzyme-adapter-react-13 |
^0.13.0 |
元素引用标识不再保留
enzyme 的新架构意味着 react “渲染树”被转换为一个中间表示,该表示在所有 react 版本中都是通用的,以便 enzyme 可以独立于 React 的内部表示正确遍历它。这样做的一个副作用是 enzyme 不再能够访问 React 组件中的 render
返回的实际对象引用。这通常不是什么大问题,但在某些情况下可能会表现为测试失败。
例如,考虑以下示例
import React from 'react';
import Icon from './path/to/Icon';
const ICONS = {
success: <Icon name="check-mark" />,
failure: <Icon name="exclamation-mark" />,
};
const StatusLabel = ({ id, label }) => <div>{ICONS[id]}{label}{ICONS[id]}</div>;
import { shallow } from 'enzyme';
import StatusLabel from './path/to/StatusLabel';
import Icon from './path/to/Icon';
const wrapper = shallow(<StatusLabel id="success" label="Success" />);
const iconCount = wrapper.find(Icon).length;
在 v2.x 中,iconCount
将为 1。在 v3.x 中,它将为 2。这是因为在 v2.x 中,它将找到所有与选择器匹配的元素,然后删除所有重复项。由于 ICONS.success
在渲染树中包含两次,但它是一个被重用的常量,因此在 enzyme v2.x 中它将显示为重复项。在 enzyme v3 中,被遍历的元素是底层 react 元素的转换,因此是不同的引用,导致找到两个元素。
虽然这是一个重大更改,但我相信新行为更接近人们实际期望和想要的行为。让 enzyme 包装器不可变会导致确定性更高的测试,这些测试不太容易受到外部因素的影响。
在状态更改后调用 props()
在 enzyme
v2 中,执行将更改组件状态(进而更新道具)的事件将通过 .props
方法返回那些更新的道具。
现在,在 enzyme
v3 中,你需要重新查找组件;例如
class Toggler extends React.Component {
constructor(...args) {
super(...args);
this.state = { on: false };
}
toggle() {
this.setState(({ on }) => ({ on: !on }));
}
render() {
const { on } = this.state;
return (<div id="root">{on ? 'on' : 'off'}</div>);
}
}
it('passes in enzyme v2, fails in v3', () => {
const wrapper = mount(<Toggler />);
const root = wrapper.find('#root');
expect(root.text()).to.equal('off');
wrapper.instance().toggle();
expect(root.text()).to.equal('on');
});
it('passes in v2 and v3', () => {
const wrapper = mount(<Toggler />);
expect(wrapper.find('#root').text()).to.equal('off');
wrapper.instance().toggle();
expect(wrapper.find('#root').text()).to.equal('on');
});
children()
现在有略微不同的含义
enzyme 有一个 .children()
方法,旨在返回包装器的渲染子项。
在使用 mount(...)
时,有时可能不清楚这到底意味着什么。例如,考虑以下 react 组件
class Box extends React.Component {
render() {
const { children } = this.props;
return <div className="box">{children}</div>;
}
}
class Foo extends React.Component {
render() {
return (
<Box bam>
<div className="div" />
</Box>
);
}
}
现在假设我们有一个类似于以下内容的测试
const wrapper = mount(<Foo />);
此时,关于 wrapper.find(Box).children()
应该返回什么存在歧义。虽然 <Box ... />
元素具有 <div className="div" />
的 children
属性,但该框组件渲染的元素的实际渲染子项是 <div className="box">...</div>
元素。
在 enzyme v3 之前,我们会观察到以下行为
wrapper.find(Box).children().debug();
// => <div className="div" />
在 enzyme v3 中,我们现在让 .children()
返回渲染的子项。换句话说,它返回该组件的 render
函数返回的元素。
wrapper.find(Box).children().debug();
// =>
// <div className="box">
// <div className="div" />
// </div>
这可能看起来是一个细微的差别,但进行此更改对于我们想要引入的未来 API 非常重要。
find()
现在返回主机节点和 DOM 节点
在某些情况下,find 将返回主机节点和 DOM 节点。例如以下内容
const Foo = () => <div/>;
const wrapper = mount(
<div>
<Foo className="bar" />
<div className="bar"/>
</div>
);
console.log(wrapper.find('.bar').length); // 2
由于 <Foo/>
具有 className bar
,因此它作为hostNode 返回。正如预期的那样,className 为 bar
的 <div>
也被返回
为避免这种情况,你可以明确查询 DOM 节点:wrapper.find('div.bar')
。或者,如果你只想查找主机节点,请使用 hostNodes()
对于 mount
,有时需要更新,而之前不需要
React 应用程序是动态的。在测试 react 组件时,你通常希望在和某些状态更改发生之前和之后对其进行测试。在使用 mount
时,整个渲染树中的任何 react 组件实例都可以随时注册代码以启动状态更改。
例如,考虑以下人为示例
import React from 'react';
class CurrentTime extends React.Component {
constructor(props) {
super(props);
this.state = {
now: Date.now(),
};
}
componentDidMount() {
this.tick();
}
componentWillUnmount() {
clearTimeout(this.timer);
}
tick() {
this.setState({ now: Date.now() });
this.timer = setTimeout(tick, 0);
}
render() {
const { now } = this.state;
return <span>{now}</span>;
}
}
在此代码中,有一个定时器不断更改此组件的呈现输出。这可能是应用程序中合理的操作。问题在于,enzyme 无法得知这些更改正在发生,也无法自动更新渲染树。在 enzyme v2 中,enzyme 直接操作 React 本身拥有的渲染树的内存表示。这意味着即使 enzyme 无法得知渲染树何时更新,更新也会反映出来,因为 React 确实知道。
enzyme v3 在架构上创建了一层,React 会在某个时间点创建渲染树的中间表示,并将其传递给 enzyme 以遍历和检查。这有很多优点,但副作用之一是现在中间表示不会接收自动更新。
enzyme 确实尝试在大多数常见场景中自动“更新”根包装器,但这些只是它已知的状态更改。对于所有其他状态更改,您可能需要自己调用 wrapper.update()
。
此问题的最常见表现可以通过以下示例显示
class Counter extends React.Component {
constructor(props) {
super(props);
this.state = { count: 0 };
this.increment = this.increment.bind(this);
this.decrement = this.decrement.bind(this);
}
increment() {
this.setState(({ count }) => ({ count: count + 1 }));
}
decrement() {
this.setState(({ count }) => ({ count: count - 1 }));
}
render() {
const { count } = this.state;
return (
<div>
<div className="count">Count: {count}</div>
<button type="button" className="inc" onClick={this.increment}>Increment</button>
<button type="button" className="dec" onClick={this.decrement}>Decrement</button>
</div>
);
}
}
这是 React 中一个基本的“计数器”组件。此处我们的结果标记是 this.state.count
的函数,该函数可以通过 increment
和 decrement
函数更新。让我们看看使用此组件的一些 enzyme 测试可能是什么样的,以及我们何时必须或不必调用 update()
。
const wrapper = shallow(<Counter />);
wrapper.find('.count').text(); // => "Count: 0"
正如我们所看到的,我们可以轻松地断言此组件的文本和计数。但我们尚未引起任何状态更改。让我们看看当我们在增量和减量按钮上模拟 click
事件时它的样子
const wrapper = shallow(<Counter />);
wrapper.find('.count').text(); // => "Count: 0"
wrapper.find('.inc').simulate('click');
wrapper.find('.count').text(); // => "Count: 1"
wrapper.find('.inc').simulate('click');
wrapper.find('.count').text(); // => "Count: 2"
wrapper.find('.dec').simulate('click');
wrapper.find('.count').text(); // => "Count: 1"
在这种情况下,enzyme 会在事件模拟发生后自动检查更新,因为它知道这是状态更改非常常见的地方。在这种情况下,v2 和 v3 之间没有区别。
让我们考虑一种不同的编写此测试的方法。
const wrapper = shallow(<Counter />);
wrapper.find('.count').text(); // => "Count: 0"
wrapper.instance().increment();
wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 1" in v2)
wrapper.instance().increment();
wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 2" in v2)
wrapper.instance().decrement();
wrapper.find('.count').text(); // => "Count: 0" (would have been "Count: 1" in v2)
这里的问题是,一旦我们使用 wrapper.instance()
获取实例,enzyme 就无法得知您是否要执行会导致状态转换的操作,因此不知道何时从 React 请求更新的渲染树。因此,.text()
永远不会更改值。
此处的修复方法是在状态更改发生后使用 enzyme 的 wrapper.update()
方法
const wrapper = shallow(<Counter />);
wrapper.find('.count').text(); // => "Count: 0"
wrapper.instance().increment();
wrapper.update();
wrapper.find('.count').text(); // => "Count: 1"
wrapper.instance().increment();
wrapper.update();
wrapper.find('.count').text(); // => "Count: 2"
wrapper.instance().decrement();
wrapper.update();
wrapper.find('.count').text(); // => "Count: 1"
在实践中,我们发现实际上并不经常需要这样做,并且在需要时添加并不困难。此外,当编写异步测试时,让 enzyme 包装器自动与真实渲染树一起更新会导致测试不稳定。此重大更改值得 v3 中新适配器系统的架构优势,我们相信对于断言库来说,这是一个更好的选择。
ref(refName)
现在返回实际 ref,而不是包装器
在 enzyme v2 中,从 mount(...)
返回的包装器在其上有一个原型方法 ref(refName)
,该方法返回该 ref 的实际元素周围的包装器。现在已更改为返回实际 ref,我们认为这是一个更直观的 API。
考虑以下简单的 React 组件
class Box extends React.Component {
render() {
return <div ref="abc" className="box">Hello</div>;
}
}
在这种情况下,我们可以在 Box
的包装器上调用 .ref('abc')
。在这种情况下,它将返回渲染的 div 周围的包装器。为了演示,我们可以看到 wrapper
和 ref(...)
的结果共享相同的构造函数
const wrapper = mount(<Box />);
// this is what would happen with enzyme v2
expect(wrapper.ref('abc')).toBeInstanceOf(wrapper.constructor);
在 v3 中,契约略有更改。ref 正是 React 将分配为 ref 的内容。在这种情况下,它将是 DOM 元素
const wrapper = mount(<Box />);
// this is what happens with enzyme v3
expect(wrapper.ref('abc')).toBeInstanceOf(Element);
类似地,如果你在复合组件上有一个 ref,则 ref(...)
方法将返回该元素的实例
class Bar extends React.Component {
render() {
return <Box ref="abc" />;
}
}
const wrapper = mount(<Bar />);
expect(wrapper.ref('abc')).toBeInstanceOf(Box);
根据我们的经验,这通常是人们实际希望和期望从 .ref(...)
方法中得到的结果。
要获取 enzyme 2 返回的包装器
const wrapper = mount(<Bar />);
const refWrapper = wrapper.findWhere((n) => n.instance() === wrapper.ref('abc'));
使用 mount
,可以在树的任何级别调用 .instance()
enzyme 现在允许你在渲染树的任何级别获取包装器的 instance()
,而不仅仅是在根级别。这意味着你可以 .find(...)
特定组件,然后获取其实例并调用 .setState(...)
或实例上的任何其他方法。
使用 mount
,不应使用 .getNode()
。.instance()
执行其过去执行的操作。
对于 mount
包装器,.getNode()
方法过去用于返回实际组件实例。此方法不再存在,但 .instance()
在功能上等同于 .getNode()
过去的功能。
使用 shallow
,应将 .getNode()
替换为 getElement()
对于浅层包装器,如果您之前使用 .getNode()
,您将需要用 .getElement()
替换这些调用,它现在在功能上等同于 .getNode()
过去所做的。一个需要注意的是,以前 .getNode()
会返回在您正在测试的组件的 render
函数中创建的实际元素实例,但现在它将是一个结构上相等的 react 元素,但不是引用相等的。您的测试需要更新以解决此问题。
私有属性和方法已被移除
enzyme “wrapper” 上有几个属性被认为是私有的,因此没有记录。尽管没有记录,但人们可能依赖于它们。为了尽量减少将来意外中断更改,我们决定将这些属性正确地“私有化”。以下属性将不再可以在 enzyme shallow
或 mount
实例上访问
.node
.nodes
.renderer
.unrendered
.root
.options
Cheerio 已更新,因此 render(...)
也已更新
enzyme 的顶级 render
API 返回一个 Cheerio 对象。我们使用的 Cheerio 版本已升级到 1.0.0。对于使用 render
API 在 enzyme v2.x 和 v3.x 之间调试问题,我们建议查看 Cheerio 的变更日志,并在该存储库上发布问题,而不是 enzyme 的,除非您认为这是 enzyme 使用该库中的一个错误。
CSS 选择器
enzyme v3 现在使用真正的 CSS 选择器解析器,而不是它自己的不完整解析器实现。这是通过 rst-selector-parser 完成的,它是 scalpel 的一个分支,它是一个使用 nearley 实现的 CSS 解析器。我们认为这不会导致 enzyme v2.x 到 v3.x 之间出现任何中断,但如果您认为您确实发现了导致中断的问题,请向我们提交问题。感谢 Brandon Dail 让这一切成为可能!
CSS 选择器结果和 hostNodes()
enzyme v3 现在返回结果集中所有节点,而不仅仅是 html 节点。考虑这个例子
const HelpLink = ({ text, ...rest }) => <a {...rest}>{text}</a>;
const HelpLinkContainer = ({ text, ...rest }) => (
<HelpLink text={text} {...rest} />
);
const wrapper = mount(<HelpLinkContainer aria-expanded="true" text="foo" />);
在 enzyme v3 中,表达式 wrapper.find("[aria-expanded=true]").length)
将返回 3,而不是像以前版本中那样返回 1。使用 debug
仔细查看会发现
// console.log(wrapper.find('[aria-expanded="true"]').debug());
<HelpLinkContainer aria-expanded={true} text="foo">
<HelpLink text="foo" aria-expanded="true">
<a aria-expanded="true">
foo
</a>
</HelpLink>
</HelpLinkContainer>
<HelpLink text="foo" aria-expanded="true">
<a aria-expanded="true">
foo
</a>
</HelpLink>
<a aria-expanded="true">
foo
</a>
要仅返回 HTML 节点,请使用 hostNodes()
函数。
wrapper.find("[aria-expanded=true]").hostNodes().debug()
现在将返回
<a aria-expanded="true">foo</a>;
节点相等现在忽略 undefined
值
我们已经更新 enzyme,以语义相同的方式考虑节点“相等”,就像 react 对待节点的方式一样。更具体地说,我们已经更新了 enzyme 的算法,以将 undefined
道具视为等同于没有道具。考虑以下示例
class Foo extends React.Component {
render() {
const { foo, bar } = this.props;
return <div className={foo} id={bar} />;
}
}
使用此组件,enzyme v2.x 中的行为将如下所示
const wrapper = shallow(<Foo />);
wrapper.equals(<div />); // => false
wrapper.equals(<div className={undefined} id={undefined} />); // => true
使用 enzyme v3,行为现在如下所示
const wrapper = shallow(<Foo />);
wrapper.equals(<div />); // => true
wrapper.equals(<div className={undefined} id={undefined} />); // => true
生命周期方法
enzyme v2.x 有一个可选标志,可以传递给所有 shallow
调用,这将使更多组件的生命周期方法被调用(例如 componentDidMount
和 componentDidUpdate
)。
使用 enzyme v3,我们现在默认开启此模式,而不是使其成为选择加入。现在可以改为选择退出。此外,您现在可以在全局级别选择退出。
如果您想在全局范围内选择退出,您可以运行以下命令
import Enzyme from 'enzyme';
Enzyme.configure({ disableLifecycleMethods: true });
这将在全局范围内将 enzyme 默认恢复为以前的行为。如果您只想针对特定测试将 enzyme 选择退出到以前的行为,则可以执行以下操作
import { shallow } from 'enzyme';
// ...
const wrapper = shallow(<Component />, { disableLifecycleMethods: true });