NHN Cloud Meetup 編集部
実用的なフロントエンドのテスト戦略(2)
2019.07.29
2,719
1部では、テストの自動化とテスト戦略の重要性、視覚的なテストの自動化が難しい理由などについて説明しました。視覚テストの自動化は不可能ではありませんが、現在のツールでは期待するほどの効果が得られないでしょう。
ストーリーブックは、テストツールというよりも、UIの開発環境に近いです。ストーリーブックの最大の目的は「UIコンポーネントをアプリケーションの外部に独立した環境で開発できるように」するものです。しかし、私たちが一般的に使用するテストツールは、「モジュール、あるいは関数をアプリケーション外部の独立した環境で実行して結果を検証できるように」サポートするものだと考えると、ストーリーブックもテストツールの役割を一部担っていることが分かるでしょう。
(この記事で作成したソースコードは、GitHubリポジトリに公開しています。この記事では重要なソースコードを中心に表示していますが、完全なソースコードが知りたい場合は、リポジトリから直接ソースコードをご確認ください。)
ストーリーブックを起動する
ストーリーブックは、当初Reactストーリーブックとして開始されましたが、現在は、Reactネイティブ、View、Angular、Ember、Riotなど、ほとんどのフレームワークに対応しています。もちろん、フレームワークを使用せず、直接DOMを操作しても使用できます。対応しているフレームワークのリストは、リポジトリのappフォルダで確認できます。
サポート対象のフレームワーク別にnpmモジュールが提供されているので、ストーリーブックを使用するには、プロジェクトの環境に合わせてnpmモジュールをインストールする必要があります。この記事では、React基盤のタスク管理アプリケーションをテストするため、Reactバージョンのストーリーブックをインストールする必要があります。ストーリーブックはこのようなインストールプロセスを楽にしてくれるCLIツールを提供しており、npxコマンドを使って別途インストールすることなく簡単に使用することができます。
(現在はストーリーブック5バージョンがリリースされていますが、記事を作成したときに最新であったストーリーブック4.1.4バージョンを使用しています。)
npx -p @storybook/cli sb init
コマンドラインに、上のコマンドを入力して実行すると、package.jsonの依存性を読み込み、どのようなフレームワークを使用しているか自動判別して、適切なバージョンのストーリーブックをインストールしてくれます。最初の起動で必要な一部ボイラープレートを一緒にインストールしてくれるので、別途設定することなく、すぐにストーリーブックを開始することができます。
プロジェクトフォルダを開くと、.storybookフォルダとstories
フォルダが追加されています。.storybook
フォルダは、ストーリーブックを使用するための設定ファイルが保存されており、stories
フォルダは実際にコンポーネントを登録するコードを記述している部分です。また、package.json
ファイルにstorybook
とbuild-storybook
と呼ばれるスクリプトが追加されています。build-storybookは後で説明しますので、まずstorybook
スクリプトを使って、ストーリーブックを実行してみましょう。
npm run storybook
上のコマンドを実行すると、9009ポートにローカルWebサーバーが実行されて、ブラウザが自動起動し、該当ページ(localhost:9009)が表示されます。たった2行のコマンドラインで、すべてのインストールと設定が完了しました。CLIツールの助けを借りずに、直接インストールと設定がしてみたい場合は、公式文書を参照してください。
ストーリーを作成する
ストーリーブックでは、テストケースという名前の代わりに「ストーリー」という名前を使います。通常のテストケースが、1つのモジュールの1つの入力値に対する結果を検証するのと同様に、ストーリーも通常は1つのコンポーネントの1つの状態を表現します。最も簡単なコンポーネントである<Header>コンポーネントをストーリーに登録してみましょう。
stories/index.jsファイルを開くと、CLIツールが<Button>
コンポーネントのサンプルを登録してくれています。これを削除して、次のようなコードを作成してみましょう。
import React from 'react'; import {storiesOf} from '@storybook/react'; import {Header} from '../components/Header'; import '../components/App.css'; const stories = storiesOf('TodoApp', module); stories.add('Header', () => ( <div className="todoapp"> <Header addTodo={() => {}} /> </div> ));
storiesOf関数は、ストーリーを登録して複数のストーリーを管理できるオブジェクトを返却します。最初の引数は一種のカテゴリー名と同じ役割をし、登録されているストーリーを1つのカテゴリーにまとめて表示する際に使用します。2番目の引数であるmoduleは、ストーリーブックが内部的にHot Module Replacementを用いてページを更新することなく変更を適用するのに必要なもので、常に記載しなければなりません。
storiesOfから返却されたオブジェクトのaddメソッドを使用すると、ストーリーを登録できます。最初の引数はストーリーの名前で、2番目の引数は、コンポーネントをレンダリングするため、Reactエレメントを返却する関数です。サンプルでは、<Header>
コンポーネントがtodoapp
クラスを持つDOM要素の下位にあることで、CSSが正常に適用されることから、<div className=”todoapp”>
で最上位のノードに追加しました。また、行為をテストすることはないので、addTodo
を空の関数として提供し、エラーが発生しないようにします。
このコードを保存すると、ブラウザで次のような画面を確認できるでしょう。
単一コンポーネントの状態に応じたストーリーの作成
<Header>コンポーネントは、propsによって変化する状態がないので、上記のように簡単にストーリーを登録できます。しかし、propsによって状態が変わるコンポーネントであれば、それぞれの状態のストーリーを別々に登録した方がよいでしょう。例えば、 コンポーネントは、タスク内容の他に「一般」「完了」「編集中」という3つの状態を持つため、次のようにストーリーを別途登録する必要があります。
stories.add('TodoItem - Normal', () => ( <div className="todoapp"> <ul className="todo-list"> <TodoItem id={1} text="Have a Breakfast" completed={false} editing={false} /> </ul> </div> )); stories.add('TodoItem - Completed', () => ( <div className="todoapp"> <ul className="todo-list"> <TodoItem id={1} text="Have a Breakfast" completed={true} editing={false} /> </ul> </div> )); stories.add('TodoItem - Editing', () => ( <div className="todoapp"> <ul className="todo-list"> <TodoItem id={1} text="Have a Breakfast" completed={false} editing={true} /> </ul> </div> ));
注意すべき点は、<TodoItem>を正しく表示するために、親要素である<ul className=”todo-list”>
が必要だということです。このようにストーリーを作成すると、次のように<TodoItem>
の個別の状態に対してストーリーが追加されたことが分かります。
単一コンポーネントストーリーの問題
これまでに作成したストーリーは、すべて子コンポーネントを持たない単一コンポーネントのストーリーでした。通常コンポーネントといえば、「リストアイテム」「ボタン」「入力ボックス」のように、非常に小さな単位のコンポーネントが思い出されますが、ストーリーもこのような小さな単位にのみ作成します。しかし、実際のアプリケーションでは、コンポーネントの組み合わせによって作成され、複数のコンポーネントを組み合わせた複合コンポーネントも多く存在します。複合コンポーネントのストーリーを作成せずに、単一コンポーネントにのみストーリーを作成するのは、統合テストを作成せずに最小単位のユニットテストだけを作成することと同じことだと言えます。
1部で紹介した「良いテストの条件」を考慮すると、このようなアプローチには、次のような問題があります。
1.実際のアプリケーションのコンポーネントの組み合わせが検証できない
当然ですが、個々のコンポーネントが視覚的に問題なく表示されても、全体のアプリケーションが視覚的に問題なく表示されるとは限りません。特にHTML/CSSで構成されたUIは、各DOM要素の親/子関係、手順、CSSセレクタ、z-indexなど、多くの要因によって影響を受けます。実際のアプリケーションが問題なく表示されるか確認するには、各コンポーネントが正しい順序で組み合わされているか、互いに影響していないかなどを確認しなければなりません。
また、あまりに小さな単位で作成されたストーリーは、実際のデザイン試案と視覚的に比較するのが困難です。複数のボタンが1つの画面に配置されたデザイン試案に対して、ボタンのみが表示されるストーリーを都度変更して検証するのは面倒ですね。
2.親コンポーネントの内部実装を変更する際、壊れやすくなる
先述の<TodoItem>のストーリーで、単一コンポーネントが正常に表示されるように、<div className=”todoapp”>
と<ul className=”todo-list”>
などを追加したことを思い出してみましょう。この場合、デザイン的な変更がなくても、リファクタリングなどにより、親コンポーネントの内部実装が変わると、ストーリーが正しく表示されません。つまり、親コンポーネントの内部実装が変更される度に、ストーリーを変更する必要があります。
また、コンポーネントのpropの値を直接注入していることから、当該コンポーネントのpropインターフェースが変更された場合も、ストーリーを一緒に変更しなければなりません。実際のところ、いかなるコンポーネントのpropsインターフェースが変更されようとも、当該コンポーネントを使用する親コンポーネントの立場では、内部実装が変更されたも同然だからです。コンポーネントを小さな単位で使うほど、影響を受ける親コンポーネントの数が増える傾向にあり、コンポーネント単位が小さくなるほど、ストーリーの管理コストは増加します。
複合コンポーネントストーリーの問題
今回は逆に、非常に大きな単位のコンポーネントを使って、ストーリーを作成する場合を考えてみましょう。このときは、次のような問題が生じることがあります。
1.個別コンポーネントのエッジケースが検証しにくい
3つのコンポーネントがそれぞれ3つの状態を持つと仮定すると、すべてのコンポーネントを組み合わせた状態では、最大27(3*3*3)の状態があると言えます。この場合、すべてのケースを個別ストーリーとして登録すると、重複が大量に発生します。
2.コンポーネントの入力値を提供するのが難しい
コンポーネントが複雑であるほど、入力値の組み合わせも複雑になります。1つのコンポーネントに3〜4個の入力値だけを提供していても、コンポーネントが5つ集まれば20個近くの入力値を提供しなければなりません。また、Reduxストアのような別途の状態管理オブジェクトを使用している場合、子コンポーネントのうちストアなどに接続しているコンポーネントが1つでもあれば、その状態の管理オブジェクトも注入しなければなりません。
3.外部環境への依存性が増加する
コンポーネントは単にビジュアル要素を表現するだけではなく、外部環境に反応して様々な付随効果を作り出すことがあります。ブラウザのURL変更に伴うルーティングを処理したり、コンポーネントがマウントされるときにAPIサーバーにリクエストを送信してデータを受け取ることなどが例に挙げられます。コンポーネントの単位が高くなるほど、これらの役割をするコンポーネントが含まれる確率が高くなり、外部環境への依存を制御する方法が必要となります。
ストーリーの単位を決める
このように両極端で利用する場合は、それぞれ長短があります。したがって、アプリケーションの性格に合わせて適切な大きさでストーリーの単位を分けることが重要です。通常はページ単位のコンポーネントが使用されますが、レイアウト上のコンテンツ領域に対応するコンポーネントだけ別途分離してストーリーに登録することもあります。特にページのコンテンツと関係がないレイヤーなら、別のストーリーに分離した方がよいでしょう。こうしておくと単一コンポーネントを登録するときに発生する問題はほとんど解決できます。
その代わり、複合コンポーネントストーリーの問題は、別の解決方法が必要です。1番目の問題は、Knobsアドオンなどを使って1つのストーリーで多くの状態を検証する方法で解決できるでしょう。2番目の問題は、様々な状態を一度に表現できる入力値を作成して、共通使用する方法で緩和できます。ReduxストアなどをMockingしてカスタムアドオンの形で作成すると、ストアなどに入力値を注入するコードを単純化できます。3番目の場合は、実際のアプリケーションコードをうまく構成することが重要です。外部環境に依存性を持つコンポーネントをできる限り最上位に移動させ、ビジュアル要素を担当するコンポーネントと役割を確実に分離することが有用です。ほとんどの付随効果は、redux-thunk、redux-sagaなど、別レイヤーで処理するように作成し、コンポーネントを最大限純粋に維持するとよいでしょう。
タスク管理アプリケーションに適用
おそらく、説明だけではまだピンとこないと思います。ここからは実際のアプリケーションに適用して、問題点を1つずつ解決していきましょう。
コンポーネントのビジュアル要素の分離
タスク管理アプリケーションは、一般的なアプリケーションに比べて規模が小さくシンプルなので、最上位のコンポーネントを直接ストーリーに登録してもよいでしょう。しかし、最上位コンポーネントは、ルーティング、ストアの作成と注入、初期データのロードなどの役割を担当しているので、このようなコードが混ざっている場合は、別途分離しましょう。サンプルソースを見ると、src/index.jsファイルのほとんどのタスクが処理され、コンポーネントはレンダリングの役割のみ担当していることが分かります。
// components/App.js import React from 'react'; import Main from './Main'; import Header from './Header'; import Footer from './Footer'; import './App.css'; export default class App extends React.Component { render() { return ( <div className="todoapp"> <Header /> <Main /> <Footer /> </div> ); } }
ストアモッキング
<App>コンポーネントのストーリーを作成してみましょう。しかし、ストーリーを作成すると、コンポーネントの入力値を提供する方法がありません。<App>
コンポーネントには別途propがなく、子コンポーネントをレンダリングするだけです。子コンポーネントは、すべての入力値をReduxストアから注入します。つまり、入力値を提供するにはストアが必要です。しかし、ストアはReducerとアクションから状態を変更できるため、入力値を希望の形式で提供することが難しいです。その代わり、ストアのAPIで次のように簡単にモックオブジェクトを作成できます。
function createMockStore(initialState) { return { dispatch() {}, subscribe() {}, getState() { return initialState; }, }; }
現在のところ、このストアの役割は初期入力値を提供するだけで、ストアの状態を変更する必要がなく、dispatch、subscribeなどのメソッドは実装する必要がありません。getState
メソッドが初期入力値を正しく返却すれば完了します。
ストーリーの作成
入力値を提供できるようになったので、ストーリーを作成することができます。しかし問題は、子コンポーネントのストア以外の入力値が必要なコンポーネントがあるということです。それは、react-routerから伝達されるデータです。<Footer>や<Main>のコンポーネントもwithRouter
を通じて、ルーターから現在のページのパラメータ情報を取得しています。そのため、最上位のコンポーネントをレンダリングするとき、ルーターの情報を提供するコンポーネントで囲まなければなりません。ただし、実際のアプリケーションで使用されるBrowserRouter
を使う場合は、ブラウザのURLに影響を受けるため、入力値を制御できる他の種類のルーターを使用するか、直接モックルーターを作成する必要があります。ここでは、バックエンド環境で主に使用されるStaticRouterを使います。
では、これから実際のストーリーを作成してみましょう。まずcreateMockStoreを使ってモックストアを生成し、<Provider>コンポーネントと<StaticRouter>
コンポーネントを一緒にレンダリングします。入力値は、可能な限り単純な項目だけ提供しましょう。
stories.add('App', () => { const store = createMockStore({ todos: [ { id: 1, text: 'Have a Breakfast', completed: false } ] }); return ( <Provider store={store}> <StaticRouter location="/" context={{}}> <Route path="/:nowShowing?" component={App} /> </StaticRouter> </Provider> ); });
これで、画面に次のような全体アプリケーションが表示されます。
入力値を設定する
全体アプリケーションを1つのストーリーで作成したので、最大限に一目で様々な状態を確認できるようにするとよいでしょう。例えば、単一コンポーネントを使用するときはToDo項目の状態によって、それぞれ異なるストーリーを登録しましたが、今はそれぞれ異なった状態を持つ項目を同時に表示できます。
const store = createMockStore({ todos: [ { id: 1, text: 'Have a Breakfast', completed: false }, { id: 2, text: 'Have a Lunch', completed: true }, { id: 3, text: 'Have a Dinner', completed: false } ], editing: 3 });
入力値をこのように作成すると、次のように 「一般(1)」「完了(2)」「編集中(3)」の項目を一目で確認することができます。
Knobsアドオンを使用する
ToDo項目の様々な状態を一目で見られるようになりましたが、まだ検討が必要な状態がたくさん残っています。(下段の「All」「Active」「Completed」ボタンがそれぞれ有効化したときや、上段の「全選択」チェックボックスの状態など)このような個々のコンポーネントの状態に、それぞれ異なる<App>ストーリーを登録すると、変更を調べるのも難しく、管理コストもかかります。アドオンを使うと、このような問題を解決できます。
アドオンは、ストーリーブックの主要な機能の1つです。主にストーリー上に登録されたコンポーネントと相互作用するときに使用され、ストーリーが見える「プレビュー」領域外にある「パネル」領域から、ストーリーを操作したり、内部情報を確認できます。アドオンの詳しい説明と使用方法は、公式文書で確認できます。またアドオンギャラリーから有用なアドオンも確認できます。
このうちKnobsアドオンは、パネルに入力コントロールを追加して、コンポーネントに提供される入力値を動的に変更できるようにサポートします。これを利用すると、単一ストーリーで細部状態を変更しながら画面を確認できます。Knobsアドオンの詳しい使い方は、GitHubリポジトリを参照してください。
Knobsアドオンのインストールと設定
インストールと設定は非常に簡単です。まず、次のnpmコマンドを使ってアドオンをインストールします。
npm install @storybook/addon-knobs --save-dev
./stories/addons.jsファイルを開くと、すでにストーリーブックCLIがデフォルトで追加しているコードがあります。すべて削除して次のコードを追加します。
import '@storybook/addon-knobs/register';
次にストーリーが登録されたsrc/stories/index.jsファイルを開いて、上部に以下のコードを追加しましょう。
import { withKnobs, radios } from '@storybook/addon-knobs';
最後にstoriesOfで作成されたオブジェクトにデコレーターを追加すれば、準備完了です。
const stories = storiesOf('Todo-App', module) .addDecorator(withKnobs);
ルーターの状態を制御する
ルーターの入力値を制御して下段のボタンの状態を変えてみましょう。ここではラジオボタンを使用するのでradios関数を使います。最初の引数はラベル名、2番目の引数はラジオボタンのオプションリスト、最後はデフォルトです。
stories.add(App, () => { // ... 既存コードと同じ const location = radios('Filter', { All: '/All', Active: '/Active', Completed: '/Completed' }, '/All'); return ( <Provider store={store}> <StaticRouter location={location}> <Route path="/:nowShowing" component={App} /> </StaticRouter> </Provider> ); });
<StaticRouter>はlocationの値を使ってルーターのURLを任意に設定できます。locationの値を指定するとき、文字列の代わりにradios
関数が返却する値を使用すると、Knobsアドオンに接続されます。コードを保存すると、次のようにKnobsパネルにFilterの項目が追加されます。各ラジオボタンをクリックすると、Footerのボタンの状態が変更されることを確認できます。
ストアの状態を制御する
では、「全選択」チェックボックスが正しく表示されるか確認してみましょう。「全選択」のチェックボックスは、すべてのToDo項目がcompleted状態であることが前提のため、状態を変更するにはストアの状態を変更する必要があります。しかし、<Provider>
コンポーネントは、最初に指定されたストアオブジェクトの参照変更を許容しないため、毎回createMockStore
を呼び出す方式では、ストアの状態を変更することができません。また、ストアは通常dispatch
によってのみ状態を変更するので、希望する状態を任意で指定するには、setStateのような別途メソッドを追加実装する必要があります。
つまり、ストアとKnobsアドオンを接続するには、ストーリーに登録された関数が実行される度に、ストアを新規に作成せず、既存のストアの状態だけを変更しなければなりません。この場合、直接アドオンを作成してデコレーターの形で使用すると、一連の作業をはるかに単純にできます。実装コードは、ストーリーブックのチュートリアルドキュメントやstoreアドオンのソースコードを参考にしてください。
ここで作られたアドオンを使って、Knobsアドオンとストアを接続する過程を見てみましょう。完成したコードは次のとおりです。
import React from 'react'; import {storiesOf} from '@storybook/react'; import {withKnobs, radios, boolean} from '@storybook/addon-knobs'; import {StaticRouter, Route} from 'react-router-dom'; import {withStore} from './addons/store'; import App from '../components/App'; const stories = storiesOf('Todo-App', module) .addDecorator(withKnobs) .addDecorator(withStore); stories.add( 'App', () => { const options = { All: '/All', Active: '/Active', Completed: '/Completed' }; const location = radios('Filter', options, options.All); return ( <StaticRouter location={location} context={{}}> <Route path="/:nowShowing" component={App} /> </StaticRouter> ); }, { state: () => { const isAllCompleted = boolean('Complete All', false); const editing = boolean('Editing', false) ? 3 : null; return { todos: [ { id: 1, text: 'Have a Breakfast', completed: isAllCompleted || false }, { id: 2, text: 'Have a Lunch', completed: isAllCompleted || true }, { id: 3, text: 'Have a Dinner', completed: isAllCompleted || false } ], editing }; } } );
既存のモックストアを生成し、<Provider>からストアを提供したロジックが削除され、add
関数の3番目の引数としてstate
と呼ばれるキーを持つオブジェクトが伝達されることが分かるでしょう。add関数の3番目の引数は、addDecorator
に登録したデコレータが渡される値を指定する際に使用します。withStore
デコレータはstateという名前のキーの値を受け取り、内部的にストアの状態を更新します。また、Knobsのパネルで値を変更すると、3番目の引数の値も新たに更新する必要があるため、stateの値は関数に伝達されます。
addon-knobsモジュールから提供されるboolean
関数を使用すると、トグル機能を簡単に追加することができます。上のコードでは、全選択の状態と、現在編集中のToDo項目を切り替えられる機能も追加しています。上のコードを実行すると、次のようにKnobsパネルにComplete All、Editingというラベルが追加されます。
ストーリーブックの共有
上記のように作成されたストーリーは、静的ファイルの形式でWebサーバーに配置することもできます。npmスクリプトであるbuild-storybookを実行してみましょう。これによって、プロジェクトのルートにstorybook-staticフォルダが生成されます。このフォルダをGitHubページなどの静的サーバーを使って展開すると、開発者だけでなく、プランナー、デザイナーなど他部門のメンバーも、対象プロジェクトのストーリーが確認できるようになります。このようにサーバーに配置されたページを、デザイン、QA、ドキュメントなどのコミュニケーションツールとして使用すると、非常に有用になります。
(ストーリーブックをコミュニケーションツールとして活用する方法は、The delightful storybook workflowで詳しく説明されています。)
この記事で作成したストーリーもGitHubページに登録されているので、確認してみてください。
おわりに
このように、タスク管理アプリケーションは、1つのストーリーですべての状態を検証できます。単純にpropを注入するだけで済む単一コンポーネントに比べるとコードが複雑になりましたが、最終的に作成されたストーリーのコードを見ると、検証用の入力値以外に不要なコードがなく、一目でよく理解ができます。また、全体ライン数が40行程度なので、単一コンポーネントを複数登録するよりもコードの量はかなり削減できました。
さらに複雑なアプリケーションの場合は、1つのストーリーですべての状態を検証することはできないでしょう。アプリケーションの規模や特徴に応じて、どのような単位でストーリーを分割するかを慎重に検討する必要があります。前述した単一コンポーネントストーリーと複合コンポーネントストーリーの長短を参考にして適切な戦略を立てましょう。
ここで作成したストーリーブックページを確認すると、ビジュアル要素は表示されますが、ユーザー入力を処理する部分はほとんどが動作しません。ストーリーブックは、単にビジュアル要素を目で確認するための用途であることを忘れないようにしましょう。1部でも言及しましたが、ビジュアル要素を他の部分と分離する理由は、テストを自動化するのが難しいからです。残りの機能的な要素に対するテストの自動化については、3部でCypressを使って紹介したいと思います。
おまけ:視覚的な回帰テスト
視覚的な回帰テストを自動するツールも、最近は大部分がストーリーブックに対応しています。代表的なツールに、Perci、Applitools、Chromaticなどがあります。関連リンクを載せておきますので、興味のある方は確認してみてください。
- Percy:Visual testing for Storybook for React
- Applitools:Visual Testing With Storybook
- Chromatic:Storybook and Chromatic tutorial