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 的函式,它可以透過 incrementdecrement 函式更新。讓我們看一下此元件的一些 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 的 shallowmount 實例上不再可以使用

  • .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 呼叫的選用旗標,有了這個旗標,可以使用更多元件的生命週期方法(例如 componentDidMountcomponentDidUpdate)。

使用 enzyme v3,我們現在已預設開啟這個模式,而不是採用選用的方式。現在可以改為選擇不使用。此外,現在您可以在全球層級選擇不使用。

如果您想在全球層級選擇不使用,可以執行下列操作

import Enzyme from 'enzyme';

Enzyme.configure({ disableLifecycleMethods: true });

這會在全球層級將 enzyme 預設回前一個行為。如果您只想針對特定測試讓 enzyme 選擇不使用前一個行為,可以執行下列操作

import { shallow } from 'enzyme';

// ...

const wrapper = shallow(<Component />, { disableLifecycleMethods: true });