NHN Cloud Meetup 編集部
実用的なフロントエンドのテスト戦略(1)
2019.07.18
8,287
現在はまさしく、JavaScriptの時代と言えるでしょう。最近の2〜3年で、JavaScriptは最も人気のある言語ランキング1位を維持しており、今も急速に成長しています。10年前、Web標準という概念すらないときからフロントエンドの開発を続けてきた開発者にとっては、非常に感慨深いものがあるでしょう。当時は開発環境という言葉すら恥ずかしいほどに不毛な環境でしたが、現在流通している新しい技術と開発ツールを眺めてみると、まさに”豊かな時代”と言えるでしょう。
なかでも、特に有望なのは「テスト方法論とツールの発展」でしょう。この数年間、フロントエンドのテストは、はるか彼方にある幻想のようなものでした。従来のどのような方法を試してみても、フロントエンドのコードテストには適しておらず、テストコードを作成する努力に比べて、実際に得られる効果はわずかなものでした。しかし、最近登場したテストツールは、過去の試行錯誤によって培われた経験が実を結び、JavaScriptの開発にとって最適なソリューションになっています。
この記事では、これら最新のテストツールの中で最も注目すべきツールであるストーリーブックとCypressを紹介します。実際に使ってみながら、実用的なフロントエンドのテストに向けて、どのような戦略を立てるべきか一緒に考えてみましょう。
開発者とテスト
まず、この記事で扱うテストの意味を明確にしておきます。ソフトウェアの観点からテストを定義する場合、「アプリケーションの要件に合わせて動作するか検証する行為」といった程度になるでしょう。通常は開発の成果物が、最終的にユーザーに配信される前にQA(Quality Assuarance)と呼ばれるプロセスを踏みますが、このプロセスをテストと見るのが一般的です。しかし、実際に開発プロセス全体を見てみると、これらの検証が各段階で着実に実施されていることが分かります。たとえば、プロトタイプの過程でUXを事前に検証し改善すること、サーバーのAPIを呼び出して期待値を確認すること、マークアップが終わった後のデザイン試案と比較してみること、などがそうです。実際はこれらすべてをテストの範疇にみなすことができます。
実際の開発過程で、どれほど多くのテストが行われているか調べるために、より具体的な例を挙げてみましょう。Reactで簡単なToDo管理アプリケーションを作成し、現在は「完了」の機能を追加しているところだとします。ユーザーのクリックイベントを受け取り、ToDo項目の状態を「完了」状態に変更するコードを作成します。変更が処理されたか確認するために、その項目をクリックして、開発ツールを開き、そのコンポーネントの状態(state)を確認します。「完了」状態になった項目は、UIのチェックボックスと取り消し線の処理が必要なので、そのコンポーネントにCSSを追加し、再度その項目をクリックして、画面のデザイン試案と同じように表現されるか確認します。その後、「完了」状態をコンポーネントではなく、Reduxストアで管理するため、コードをリファクタリングして、再度その項目をクリックして、画面に以前と同じ結果が表示されるか確認します。
上記の例では、該当する項目をクリックして、結果を確認する過程はすべてテストと言えます。実際に、開発者はすでに開発過程で数多くのテストを進行しており、厳密に言えば、開発者がソースコードを入力/修正、保存して進行することは、大半がテストと関連しています。
(今後は意味を明確にするために、「テスト」という用語を「開発者が作成した自動テスト」という意味に限定して使用します。)
自動テストの重要性
問題は、これらのテストが反復的な作業であるということです。先ほどの簡単な例でも「対象の項目をクリックして」、結果を「確認する」プロセスが何度も繰り返されていますね。このような反復作業を毎回手動で行うと、アプリケーションが複雑になるほど、テストに対するコストが増大します。テストコストが増加することでテストが疎かになり、最終的にはアプリケーションの品質低下につながることがあります。またコードを変更する度に、関連機能をテストする必要があるため、その負担からコードの改善を妨げ、コードの品質を低下させることもあります。
このように、繰り返し実行されるテスト作業をコードで作成して自動化すると、テストに対するコストが減り、テスト漏れや誤った検証などのミスを防止することができます。また、コード修正の不安がなくなり、積極的にリファクタリングができるようになります。これはすなわち、コードの品質向上につながります。
テストの機会費用
おそらく、自動テストを作成することが重要であることは、誰もが同意することでしょう。だからといって、すべてのテストに自動テストを作成する必要はありません。テストコードの作成、維持にはコストがかかるからです。投入される費用に比べて得られる効果が少ない場合は、むしろ手動でテストする方がよいでしょう。テストカバレッジを100%にしようとしたり、重要なロジックがほとんどない単純なコードでもすべてテストしようとする行き過ぎた目標を設定する場合が稀にありますが、これはより重要なところに投資できる貴重なお金を浪費していることになります。
すでに作成されているテストであっても、不要だと思えば除去した方がよいでしょう。テストコードもアプリケーションが変化するにつれて、継続して管理する必要があるためです。製品コードから不要なコードを削除することが良い習慣であるように、テストコードにおいても不要なコードは積極的に除去して、メンテナンスコストを削減していくことが重要です。TDDの創始者であるケントバックでさえ、テストについては次のような意見を述べています。
…私は、テストコードではなく、正常に動作する製品コードに対して報酬を受け取る。それゆえ私の原則は、「特定レベルの信頼を確保する最小限のテストコードのみ作成する」ことである…(中略)…これといってミスを犯しそうにないコードはテストしない。
出典 : Stack Overflowの回答から
…完璧にすべてをテストしようとすると、テストコードは必然的にエラーが発生しやすい複雑なコードになる…(中略)…もしコードがあまりにも簡単で、エラーが発生する確率がほとんどないなら、テストはしない方がよい。
出典 : Extreme Programming Explained
良いテストの条件
テストの機会費用を試算するために、良いテストとは何か考えてみよう。どのようなテストコードを作成するかによって、作成やメンテナンスにかかる費用も異なり、得られる効果も違ってくるからです。
では、どのようなテストが良いテストでしょうか?テストの価値は、アプリケーションの性質、開発ツール、言語、ユーザー環境など、様々な要因によって影響を受けるため、これを定義するのは非常に難しいです。完璧なテスト基準ではないかもしれませんが、良いテストに共通している特徴を整理してみましょう。
1.実行速度が速くなければならない
テストの実行速度が速いということは、コードを変更する度に迅速なフィードバックを得ることができるという意味です。開発速度を速めて、テストをより多く実行できるようにします。結果を見るために数十分かかるテストは、開発過程においては時代錯誤であると言えるでしょう。
2.内部実装を変更するとき、壊れてはいけない
これは「インターフェースに基づいてテストを作成しよう」、「実装に依存するテストを作成してはいけない」という指針と同じ意味に捉えることができます。より広い観点では、テストの単位が小さすぎる場合にも該当します。小さなリファクタリングのテストで壊れてしまうと、コードを改善するときに信用できません。また、テストを修正するためのコストが発生し、コードの改善を妨げることになります。
3.バグを検出できなければならない
テストが期待する結果を具体的に明示しなかったり、または予測可能なシナリオをすべて検証しなければ、製品コードにあるバグを発見できないことがあります。また、モックオブジェクト(Mock)を過度に使用すると、依存性のあるオブジェクトの動作が変わっても、テストコードが接続過程でバグを全く検出できなくなります。したがって、テスト仕様は具体的でなければならず、モックオブジェクトの使用は可能な限り控えることが重要です。
4.テストの結果が安定しなければならない
昨日成功したテストが今日は失敗したり、特定の機器で成功したテストが他の機器では失敗する、といった場合、そのテストは信頼できないでしょう。つまり、テストは外部環境の影響を最小限に抑え、いつ、どこで実行しても、同じ結果を保証しなければなりません。このような外部環境は、現在の時刻、機器のOS、ネットワークの状態などを含め、直接操作できるようにモックオブジェクトや別のツールを活用しましょう。
5.意図が明確でなければならない
製品コードの可読性が重要であることは、今や誰もが認める周知の事実です。良質のコードは「機械が読みやすい」コードではなく、「人が読みやすい」コードです。テストコードも品質を高めるために、製品コードと同じ基準で管理する必要があるでしょう。つまり、テストコードを見て、一目でどんな内容かを把握できなければなりません。そうでなければ、後日コードを変更したり、削除することが難しくなり、管理コストが増大します。テスト用に冗長コードを繰り返し使用したり、結果を検証するコードが不必要なまでに複雑になる場合は、別の関数や断言文を作って抽象化させるのが望ましいでしょう。
テスト戦略の重要性
上述した良いテストの要素のうち、1つでも満足させるのはさほど難しくはありません。しかし問題は、それぞれの要素が互いに矛盾する場合があるということです。たとえば、テストを非常に小さな単位で作成すると、比較的実行速度が速く、すべてのシナリオが検証しやすいでしょう。その代わり、小さな単位の変更でもテストが壊れてメンテナンスコストが増加し、モックオブジェクトの使用が増えることでバグの検出が難しくなります。また、テスト仕様を詳細に作成すると、より多くの状況のバグを検出できるようになりますが、テストコードが複雑になって意図が明確に現れないことがあります。
最終的に、すべての要素を100%満足させるテストを作成することは事実上不可能です。だからこそ、プロジェクト、サービスモジュールなどの特性に応じて、何を諦めて何を得るのか、よく判断して戦略を立てる必要があります。特にフロントエンドのコードは、グラフィカルユーザーインターフェイス(GUI)と密接に関係しており、ユーザーの様々な実行環境を考慮する必要があるため、他のプラットフォームで使用される戦略をそのまま使用することができません。ビジュアル要素、サーバー通信、ユーザーインターフェイス(UI)を用いた入力などを、それぞれどのようにテストすべきか検討し、自分だけの戦略を立てる必要があります。
テストツールの重要性
フロントエンドのテスト戦略を立てるときは、ツールの役割も非常に重要なものになります。たとえば、従来のE2E(End To End)ツールを使用したテストでは、ユーザーの観点からテストすることができ、内部の実装にほとんど影響を受けませんが、テストコードが複雑で実行が遅く、結果が安定しないという欠点があります。しかし、最新のE2EツールCypressを使用すると、従来のE2Eテストの利点を維持しながら、直感的で迅速かつ信頼性の高いテストを作成することができます。つまり、テストツールの発展がより良いテスト戦略を手助けしてくれます。
最終的には効率的なテストを作成するためには、フロントエンドに合わせた実用的な戦略が必要であり、最新のテストツールが従来よりもさらに実用的な戦略を立てられるようにサポートしてくれます。
では、簡単なタスク管理アプリケーションを実際にテストしてみて、実用的なテスト戦略とは何であるか探っていきましょう。
簡単なサンプル:タスク管理アプリケーション
テストに使用するアプリケーションは、よく知られているプロジェクトであるTodoMVCを参照して、ReactとReduxを組み合わせて開発しました。しかし、特定のライブラリに限定した戦略を扱うわけではないので、Reactで作成していないアプリケーションにも十分適用することができます。
図1:TodoMVCアプリケーションの実行画面
(そもそもTodoMVCは永続的なデータを保存するためにlocalStorageを使用していますが、このサンプルでは、物理サーバーとの通信をテストするために、別のローカルサーバーを使用するように変更しました。)
フロントエンド・アプリケーションのコンポーネント
サーバーに保存されたデータがすでに存在すると仮定して、アプリケーションを最初に実行した後、行うべきことを新たに追加するシナリオを考えてみましょう。内部的な実行手順を考慮して順に並べると、次のようになります。
- アプリケーションが実行されると、画面にメインUIを表示する
- APIサーバーに「ToDoリスト」を要請し、応答データをReduxストアに格納する
- 保存されたストアの値に基づいてToDoリストをUIで表示する
- ユーザーが入力ボックスをクリックし、「昼寝」と入力してEnterキーを入力する
- APIサーバーに「ToDo追加」を「昼寝」というデータと一緒に要請する
- リクエストが成功すると、Reduxストアのリストに「昼寝」を追加する
- 保存されたストアの値に応じて、UIを更新する
それぞれのステップを役割に応じて分類すると、大きく2つに分類できます。最初は、現在のアプリケーションの状態を視覚的に画面に表示する作業で、1,3,7に該当します。次は、外部入力(ユーザー入力、サーバー通信)を受け取り、アプリケーションの現在の状態を変更する作業で、2,4,5,6に該当します。MVCパターンで主に使用されるモデルとビューの区分と似ていますね。
このような分類が重要な理由は、各部分をテストするときに、他の戦略を使用する必要がないためです。特にビジュアル要素のテストは、コードを利用して自動化するのが難しいので、アプリケーションの状態の変更をビジュアル要素のようにテストすると、作成とメンテナンスに多くのコストが費やされます。したがって、アプリケーションを設計する際、両方を分離してテストできる構造を準備しておくことが重要です。ReactやVueのような最新のフレームワークは、ビジュアル要素と状態の管理を分離できる方法を基本提供しています。
まず、ビジュアル要素のテスト方法について説明します。
ビジュアル要素のテスト
HTMLを比較する
よくフロントエンドで使用されるMVCパターンでビューをテストすると、HTMLの構造をテストすることが多いです。視覚的表現を決定する要素は、HTMLやCSSですが、CSSで定義されたスタイルは、動的に制御されることが稀にあるからです。最も単純な形式の検証は、予想されるHTML構造を文字列として比較するものでしょう。ヘッダ領域に対応するコンポーネントを、このような方法でテストする場合は、次のようになります。
import React from 'react'; import { render } from 'react-dom'; import prettyHTML from 'diffable-html'; import { Header } from '../components/header'; it('Header component - HTML', () => { const el = document.createElement('div'); render(<Header />, el); const outputHTML = prettyHTML(` <header class="header"> <h1>todos</h1> <input class="new-todo" placeholder="What needs to be done?" value="" /> </header> `); expect(prettyHTML(el.innerHTML)).toBe(outputHTML); });
上記のサンプルでは、diffable-htmlライブラリは、HTML文字列を比較するとき、他の部分をより把握しやすいように、既存の文字列を特定のフォーマットに合わせて変更します。こうすれば、テストが失敗したときに期待されるHTMLの構造と、実際のHTMLの構造がどのように違うか、より明確に比較することができます。また、innerHTML
属性が返却する値がブラウザの内部実装に依存する問題も解決してくれます。予想されるHTML文字列の空白や改行文字を気にしなくてもよいので、テストコードをさらに読みやすい形で作成できます。
スナップショットテスト(HTML)
上記のように、HTML文字列を比較するときに予想されるHTML文字列を、開発者が事前に作成することは、実際のところ簡単ではありません。上記のヘッダのサンプルは、比較的HTMLの構造が簡単な方ですが、通常はより大規模で複雑コンポーネントをテストしなければならない場合が多いからです。そのため、このような方式のテストを作成するとき、ブラウザやテストツールのコンソールログを利用して、実際のコンポーネントが生成したHTMLをコピーし、テストコードに貼り付けることが多いです。
このような方法は、期待される結果をあらかじめ作成する一般的なテスト駆動開発(TDD)方式とは異なります。この場合、コードを作成する際に、迅速なフィードバックを与えて開発速度を向上させる機能はほとんどなく、事実上、回帰テストの役割のみであると考えられます。また、コードを変更する度に、毎回コンソールで結果値をコピーして、コードに貼り付ける過程も面倒です。そこで最近は、Jestなどのテストツールでサポートされるスナップショットテストを使って、このような問題を解決しています。
スナップショットテストは、予想されるデータを直接コードで作成せずに、最初に実行された結果をファイルに保存しておく方式を使用します。次回からテストを実行する度に、すでに保存されたファイルの内容と、現在実行された結果を比較します。上記と同様に、回帰テストの役割のみで、期待される結果を直接コードで管理しなければならない煩わしさをなくすことができます。下記は既存のHTML比較方式をスナップショット方式のテストに変更したコードです。
import React from 'react'; import { render } from 'react-dom'; import prettyHTML from 'diffable-html'; import { Header } from '../components/header'; it('Header component - Snapshot', () => { const el = document.createElement('div'); render(<Header />, el); expect(el.innerHTML).toMatchSnapshot(); });
既存のテストコードよりもはるかに単純になりました。テストコードでは、実際の結果値を確認できませんが、代わりに__snapshot__フォルダ内に*.js.snapファイルが生成されます。このファイルには次のような結果値が保存されていることを確認できます。
exports[`Header component - Snapshot 1`] = ` " <header class=\\"header\\"> <h1> todos </h1> <input class=\\"new-todo\\" placeholder=\\"What needs to be done?\\" value > </header> " `;
スナップショットテスト(仮想DOM)
Reactのコンポーネントが返却されるのは、実際のHTMLではなく、Reactエレメントという仮想のDOM構造です。実際のHTMLの生成と変更はreact-domモジュールの役割であり、厳密に言えば、特定のコンポーネントのテスト範囲には含まれません。そのため、Reactのコンポーネントをテストするときは、コンポーネントが返却されるReactエレメントのツリー構造をテストする場合が多いようです。
Reactでは、コンポーネントのテストを支援するため、react-test-rendererライブラリを提供しています。このライブラリを使用すると、コンポーネントを実際にレンダリングすることなく、コンポーネントの動作をテストできます。
import React from 'react'; import renderer from 'react-test-renderer'; import { Header } from '../components/header'; it('Header component - Snapshot', () => { const tree = renderer.create(<Header />).toJSON(); expect(tree).toMatchSnapshot(); });
上記のサンプルでは、DOM要素を生成して直接レンダリングするコードの代替として、react-test-rendererのtoJSON()関数を利用しています。この場合は、ブラウザのレンダリングエンジンが必要ないため、JSDomなどのサポートがなくても、Node.js環境でテストを実行できる利点があります。
Jestはこの他にも、スナップショットファイルの比較や更新のため、様々な便利な機能を提供しています。スナップショットテストに関する詳しい内容は、Jestの文書をご参考ください。
HTML構造比較の問題点
これまで調べた、HTMLの文字列の比較やスナップショットテストの両方とも、ビジュアル要素をテストするために、HTMLの構造を比較する方法を使用しています(Reactエレメントツリーも、最終的にHTML構造と考えられます)。しかし、前述の「良いテストの条件」を考慮すると、HTMLのテストは次のような問題を抱えています。
1.実装依存
良いテストの条件の1つに、「内部実装を変更するとき壊れてはいけない」があります。つまり、テストをするときは、結果値を「どのように」生み出すのではなく、成果物が「何」であるかを検証しなければなりません。しかし、HTMLは厳密に言えば、ビジュアル要素の成果物ではなく、ビジュアル要素を表現するための内部実装方式、すなわち「どのように」の方に近いです。ビジュアル要素の最終的な成果物は、HTML構造ではなく、画面に表示されるイメージであるからである。
このような実装に依存するテストは、小さな変更にも壊れやすく、管理コストを増加させます。Headerコンポーネントのテストを例に挙げましょう。もし、headerタグの代わりにdiv
タグを使用したり、new-todo
クラスをadd-todoに変更すると、実際の結果イメージに変化がなくても、テストは崩れます。HTMLやCSSをリファクタリングする際も、テストコードを更新させる必要があるため、これにより開発速度がむしろ遅くなることがあります。
2.意図が明確ではない
良いテストのもう1つの条件に、「意図が明確でなければならない」があります。しかし、HTMLの構造は、実際の画面に描画される画像をそのまま表示しません。たとえCSSまで一緒にテストするとしても、複雑なHTMLとCSSのコードを見て実際のイメージを頭の中に正確に描き出すことは事実上不可能でしょう。テストを作成する際に予想されるHTMLの結果値をあらかじめコードに記述できない理由も同様です。結局、ブラウザに表示された結果を実際に目で確認して、初めて生成されたHTMLが実際に望んだ結果であるか確信できるのです。
このようなテストコードは管理が難しいですね。他の開発者が後でコードを表示するとき、どのような意図を持っているのか把握するのが難しいでしょう。結局はテストが崩れたとき、何も考えずに実行結果をコピー&ペーストしたり、スナップショットを更新するようになってしまい、テストの信頼性と効果が落ちるでしょう。
視覚テストの自動化の難しさ
ビジュアル要素は、実際の画面に表示される画像をピクセル単位で比較しない限り、効果的なテストとは言い難いでしょう。残りの方法は、実際のビューコンポーネントをブラウザで実行し、画面に表示される結果をスクリーンショットに保存して、予想される画像と比較する方法があります。デザイン試案を予想される結果値として使用すると、毎回コードを作成する度にスクリーンショットを作成し、デザイン試案と比較して同一であるか検証できます。
しかし、この方法も簡単ではありません。デザイン試案は、通常の開発に必要なコンポーネント単位で正確に分離されていないからです。また、デザイン試案には発生可能なすべてのシナリオが検討されていない場合が多いので、コンポーネントが保有するあらゆる状態を検証するための期待値として使用するには適切ではありません。他にも、画面の解像度、ブラウザの固有のレンダリング方式、ビューポートのサイズと余白などの様々な条件を考慮して、画像をピクセル単位で比較するのは技術的に多くの困難を強いられます。
最終的に現在、最も効率的な視覚的なテストツールは、依然として「開発者の目」であると思われます。視覚テストの問題点を解決するために、近年多くのツールが作られていますが、まだ「開発者の目」よりも効率的な解決策は提示されていないようです。実際にHTMLとCSSを開発する過程を考えてみましょう。開発者は、HTMLタグが、CSSのスタイルを追加して、変更する度に、毎回目で画面を確認して、別の結果値を期待します。このような一連の過程をすべて自動化できるツールが登場するには、まだ時間が必要でしょう。
それでは、視覚的なテストは自動化ができないのでしょうか?まだ完璧に自動化はできませんが、UIの開発方法は改善できます。そのための新しい方法を提示するツールが、まさにストーリーブックなのです。
(最近は、Applitools、Chromaticのようにブラウザのレンダリング方式による違いを理解してイメージを比較する視覚テストツールが数多く発達しています。これらのツールは、主に回帰テストの用途として使われており、ストーリーブックと組み合わせて使用すると、さらに有効です。第2部でストーリーブックの使い方とこれらの視覚的な回帰テストツールを紹介します。)
ストーリーブック:UI開発環境
公式ホームページでは、ストーリーブックを「UI開発環境」と紹介しています。実際にテストツールというよりは、UI開発向けのより良い環境を提供するツールに近いようです。一種のコンポーネントギャラリーとも言えますが、下図のように、アプリケーションで使用されるすべてのコンポーネントの組み合わせをページ毎に登録し、便利に目で確認できるようにナビゲーションを提供しています。
(図2:ストーリーブックサンプル- TOAST Fileで使用しているコンポーネントリスト)
では、このツールがどのように視覚的なテストを手助けしてくれるでしょうか?記事の前半で、コードを保存した後の結果を確認するすべての過程が実質的にすべてテストである、と言ったことを思い出してみよう。すべてのコンポーネントで可能な組み合わせと入力値があらかじめ保存された状態で登録しておくと、このような過程の反復作業を大幅に自動化できます。
ここで、ファイルのアップロード完了ポップアップ内のアイコンサイズを変更する場合を例に挙げてみましょう。コードを変更した後、結果を確認するには、新しいファイルをアップロードしてアップロードが完了するまで待つ必要があります。ポップアップが表示されて、結果を確認したところ、アイコンが大きすぎて文字に被っていました。そこでコードを再び変更し、確認するためもう一度ファイルをアップロードして完了するまで待ちます。確認すると今度はアイコンが小さすぎるようです。コードを修正して、またこの作業を繰り返します。
このような一連の反復作業は、ビジュアル要素のテストがアプリケーション全体の状態と結合しているため発生します。ファイルをアップロードしてアップロードされるまで待つ過程は、アプリケーションの状態を望ましい状態にするための作業です。もしストーリーブックに「アップロード完了ポップアップ」コンポーネントを別々に登録していたり、「アップロード完了ポップアップが表示された状態」があらかじめ登録されていれば、このような反復作業は必要なく、ビジュアル要素の変更のみを確認してコードを変更できたでしょう。
1部おわり
ここまでテスト自動化の重要性と良好なテストコードの条件、テスト戦略がなぜ重要なのかについて調べました。また、なぜ視覚テストの自動化が難しいのか、ストーリーブックがどのような選択肢を提示してくれるのか、についても紹介しました。もちろんフロントエンドのコードをテストするための戦略は多様で、この記事で紹介した方向が必ずしも一致するとは限りません。
2部では、実際にストーリーブックを使って、視覚テストに向けてさらに詳しい戦略を調べていきたいと思います。