enzyme v2.x 到 v3.x 的遷移指南
從 enzyme v2.x 遷移到 v3.x 的變更比過去重大的版本更顯著,因為 enzyme 的內部實作幾乎已經完全重新撰寫。
這次重新撰寫的目標是解決許多自 enzyme 初始發行以來就困擾它的重大問題。同時也可以移除 enzyme 對 React 內部依賴性的部分,並使 enzyme 更具「可插入」性,為 enzyme 能夠搭配「類 React」的程式庫,例如 Preact 和 Inferno,打好基礎。
我們盡力讓 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 的「官方支援」轉接器。這些轉接器是格式為 enzyme-adapter-react-{{version}}
的 npm 套件。
在測試中使用 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 的「render 樹」會轉換成一個中介表示,這個中介表示於所有 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 wrapper 成為不可變動,可以帶來更確定的測試,較不容易受到外部因素影響而變不穩定。
在狀態變更後呼叫 props()
在 enzyme
v2 中,執行一個會變更元件狀態的事件(反過來也會更新 props),會透過 .props
方法傳回那些已更新的 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()
方法,旨在傳回 wrapper 的已渲染子代。
在使用 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 ... />
元素的 children
屬性是 <div className="div" />
,但 box 元件所渲染元素的實際已渲染子代是一個 <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
,所以它會傳回作為主節點。正如預期的,具有 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 wrapper 自動與真正的渲染樹一起更新,可能會導致測試結果不穩定。這個重大變更是 V3 中新介接器系統的架構優點所帶來的,而我們認為這是斷言程式庫可以採取的較好選擇。
ref(refName)
現在傳回實際參考,而非 wrapper
在 Enzyme v2 中,從 `mount(...)` 傳回的 wrapper 在其上有一個原型方法 `ref(refName)`,它會傳回對該參考之實際元素的 wrapper。這項內容現在已變更為傳回實際參考,我們認為這是一個更直覺的 API。
請考慮以下簡單 React 元件
class Box extends React.Component {
render() {
return <div ref="abc" className="box">Hello</div>;
}
}
如果這樣,我們可以在 Box 的 wrapper 上呼叫 `ref('abc')`。在這種情況下,它會傳回一個圍繞已渲染 div 的 wrapper。舉例來說,我們可以看到 `wrapper` 和 `ref(...)` 的結果共用同一個建構函式
const wrapper = mount(<Box />);
// this is what would happen with enzyme v2
expect(wrapper.ref('abc')).toBeInstanceOf(wrapper.constructor);
在 v3 中,合約稍有改變。此參考正是 React 會指派為參考的內容。在這個情況下,它會是 DOM 元素。
const wrapper = mount(<Box />);
// this is what happens with enzyme v3
expect(wrapper.ref('abc')).toBeInstanceOf(Element);
類似地,如果在複合元件中有一個參考,`ref(...)` 方法將會傳回該元素的實例。
class Bar extends React.Component {
render() {
return <Box ref="abc" />;
}
}
const wrapper = mount(<Bar />);
expect(wrapper.ref('abc')).toBeInstanceOf(Box);
根據我們的經驗,這是大多數人真正希望並且預期 `ref(...)` 方法可以做到的。
取得 Enzyme 2 傳回的 wrapper
const wrapper = mount(<Bar />);
const refWrapper = wrapper.findWhere((n) => n.instance() === wrapper.ref('abc'));
針對 `mount`,可以在樹狀結構的任何層級呼叫 `instance()`
現在,對於 `instance()`,Enzyme 讓你可以擷取渲染樹中任何層級的 wrapper,而不再只有根目錄。這表示你可以 `find(...)` 特定的元件,然後擷取它的實例,並呼叫 `setState(...)` 或實例中其他你想要的任何方法。
對於 `mount`,不應使用 `getNode()`。`instance()` 執行它過去執行的動作。
對於 mount
封裝器,.getNode()
方法用於傳回實際元件實例。此方法已不再存在,但 .instance()
在功能上等同於 .getNode()
過去使用的功能。
使用 shallow
時,.getNode()
應替換為 getElement()
對於 shallow
封裝器,如果您先前使用 .getNode()
,您會想要將這些呼叫替換為 .getElement()
,現在在功能上等同於 .getNode()
過去執行的功能。一個需要注意的是,過去 .getNode()
會傳回在您所測試元件的 render
函式中建立的實際元素實例,但現在會是一個結構上相等的 react 元素,但不是引用相等的。您的測試需要更新才能應付這個改變。
私人屬性和方法已被刪除
enzyme 封裝器上有幾個屬性被視為私人屬性,因此沒有文件記載。儘管沒有文件記載,但可能有人依賴這些屬性。為了讓變更在未來不太可能意外中斷,我們決定讓這些屬性適當「私有化」。下列屬性在 enzyme 的 shallow
或 mount
實例上不再可以使用
.node
.nodes
.renderer
.unrendered
.root
.options
Cheerio 已更新,因此 render(...)
也已更新
enzyme 的頂層 render
API 會傳回一個 Cheerio 物件。我們使用的 Cheerio 版本已升級到 1.0.0。對於跨 enzyme v2.x 到 v3.x 和 render
API 的問題除錯,我們建議您查看 Cheerio 的變更日誌 並在該存放庫中而不是 enzyme 中發佈問題,除非您認為問題出在 enzyme 對該函式庫的使用。p>
CSS 選擇器
enzyme v3 現在使用實際的 CSS 選擇器剖析器,而不是自己的不完整剖析器實作。這透過 rst-selector-parser 進行,後者是 scalpel 的衍生,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 });