enzyme v2.x から v3.x への移行ガイド
enzyme v2.x から v3.x への変更は、enzyme の内部実装がほぼ完全に書き直されたため、以前のメジャーリリースよりも大きな変更となります。
この書き換えの目標は、enzyme が最初のリリース以来抱えてきた主要な問題の多くに対処することでした。また、enzyme が React の内部に依存している部分を多く削除し、enzyme をより「プラグ可能」にして、Preact や Inferno などの「React のような」ライブラリで enzyme を使用できるようにすることも目的でした。
enzyme v3 を v2.x とできる限り API 互換性を持たせるよう最善を尽くしましたが、この新しいアーキテクチャをサポートし、ライブラリの長期的な使いやすさを向上させるために、意図的に行う必要があったいくつかの破壊的な変更があります。
Airbnb には、約 30,000 の enzyme ユニットテストで構成される、最大の enzyme テストスイートの 1 つがあります。Airbnb のコードベースで enzyme を v3.x にアップグレードした後、これらのテストの 99.6% がまったく変更なしに成功しました。壊れたテストのほとんどは簡単に修正できることがわかり、一部は実際には v2.x のバグと考えられるものに依存していることがわかり、破壊は実際には望ましいものでした。
このガイドでは、遭遇した最も一般的な破壊のいくつかと、それらを修正する方法について説明します。これにより、アップグレードパスが大幅に簡単になることを願っています。アップグレード中に、理解できない破壊が見つかった場合は、遠慮なく問題を報告してください。
アダプターの構成
enzyme には「アダプター」システムが導入されました。つまり、enzyme をインストールするだけでなく、使用している React のバージョン (または使用しているその他の React のようなライブラリ) で enzyme がどのように動作するかを指示するアダプターを提供する別のモジュールもインストールする必要があります。
これを書いている時点では、enzyme は React 0.13.x、0.14.x、15.x、および 16.x の「公式にサポートされている」アダプターを公開しています。これらのアダプターは、enzyme-adapter-react-{{version}}
形式の npm パッケージです。
テストで enzyme を使用する前に、使用するアダプターで enzyme を構成する必要があります。これを行う方法は enzyme.configure(...)
です。たとえば、プロジェクトが React 16 に依存している場合は、enzyme を次のように構成します
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });
React セマンティックバージョニング範囲のアダプター npm パッケージのリストは次のとおりです
enzyme アダプターパッケージ | React セマンティックバージョニング互換性 | ||
---|---|---|---|
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 の「レンダリングツリー」は、enzyme が React の内部表現に関係なく適切にトラバースできるように、すべての 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
はレンダリングツリーに 2 回含まれていますが、再利用される定数であるため、enzyme v2.x の目には重複として表示されます。enzyme v3 では、トラバースされる要素は基になる React 要素の変換であり、参照が異なるため、2 つの要素が見つかります。
これは破壊的な変更ですが、この新しい動作は人々が実際に期待し、望むものに近いと信じています。enzyme ラッパーを不変にすると、外部要因による不安定さが少なくなり、より決定論的なテストになります。
状態変更後に props()
を呼び出す
enzyme
v2 では、コンポーネントの状態を変更するイベントを実行すると、.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()
メソッドがあります。
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
prop がありますが、ボックスコンポーネントがレンダリングする要素の実際のレンダリングされた子は <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 に渡してトラバースおよび検査するレイヤーをアーキテクチャ的に作成しました。これには多くの利点がありますが、副作用の 1 つは、中間表現が自動更新を受信しなくなったことです。
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"
実際には、これがそれほど頻繁に必要になるわけではなく、必要な場合でも追加は難しくないことがわかりました。さらに、酵素ラッパーが実際レンダーツリーと並行して自動的に更新されると、非同期テストを作成する際にテストが不安定になる可能性があります。この破壊的な変更は、v3 の新しいアダプターシステムのアーキテクチャ上の利点に見合うものであり、アサーションライブラリとしてはより良い選択であると信じています。
ref(refName)
は、ラッパーではなく実際の ref を返すようになりました
enzyme v2 では、mount(...)
から返されたラッパーには、その ref の実際の要素をラップしたラッパーを返す ref(refName)
というプロトタイプメソッドがありました。これは、より直感的な API であると考える実際の ref を返すように変更されました。
次の簡単な 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()
に置き換える必要があります
shallow ラッパーの場合、以前に .getNode()
を使用していた場合は、これらの呼び出しを .getElement()
に置き換える必要があります。これは、現在、以前の .getNode()
と機能的に同等です。注意すべき点は、以前は .getNode()
はテスト対象のコンポーネントの render
関数で作成された実際の要素インスタンスを返していましたが、現在は構造的に等しい React 要素ですが、参照的に等しいわけではありません。この点を考慮してテストを更新する必要があります。
プライベートプロパティとメソッドが削除されました
酵素 "ラッパー" には、プライベートと見なされ、その結果として文書化されなかったいくつかのプロパティがあります。文書化されていなかったにもかかわらず、人々はそれらに依存していた可能性があります。将来の変更が誤って破壊的になる可能性を低くするために、これらのプロパティを適切に「プライベート」にすることにしました。次のプロパティは、酵素の shallow
または mount
インスタンスではアクセスできなくなります
.node
.nodes
.renderer
.unrendered
.root
.options
Cheerio が更新されたため、render(...)
も更新されました
酵素のトップレベルの render
API は、Cheerio オブジェクトを返します。使用する Cheerio のバージョンは 1.0.0 にアップグレードされました。render
API を使用した enzyme v2.x および v3.x にわたるデバッグ問題については、Cheerio の変更ログ を確認し、酵素によるライブラリの使用にバグがあると思われる場合を除き、酵素のリポジトリではなく、そのリポジトリに問題を投稿することをお勧めします。
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)
は、以前のバージョンでは 1 ではなく、3 を返します。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
値が無視されるようになりました
ノードの「等価性」を React がノードを扱う方法と意味的に同一にするように酵素を更新しました。より具体的には、undefined
props を props がない状態と同等に扱うように酵素のアルゴリズムを更新しました。次の例を考えてみましょう
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 });
これにより、酵素はグローバルに以前の動作に戻ります。特定のテストのみで酵素を以前の動作にオプトアウトしたい場合は、次の操作を実行できます
import { shallow } from 'enzyme';
// ...
const wrapper = shallow(<Component />, { disableLifecycleMethods: true });