はじめに
今回紹介するのは、React や Vue などの「コンポーネント指向のフロントエンド開発」における「テスト方針の考え方」と、それを実現するための「方法論」です。
初めて React などのコンポーネント指向のテストを書くとき、どういう考え方・方針でテストを書いていくのか分からず悩んだ人も多いのではないでしょうか。
この記事ではその疑問の1つの解として、React 公式で推奨されているテストライブラリ Testing Library の開発者であり、Testing Trophy というテストコンセプト考案者でもある Kent C. Dodds の考え方とその実現方法を紹介していきます。
社内のシステムでも実際にこの Kent のコンセプトに従いテストの運用をしていますが、これによって「壊れにくいテスト」がフロントエンド(React)で書きやすくなっています。より正確に言うと「壊れるべきときには壊れてくれて、壊れるべきでないときには壊れないでいてくれるテスト」が書きやすくなっています。
他のエンジニアとの会話で「フロントエンドのテストは壊れやすくて嫌い」という話を聞くことがあるので、そういう人にはぜひこの Kent のコンセプトに沿ったテストの構築を試してみてもらいたいと思います。
追記
React 公式でも Testing Library
が推奨されました。
React Testing Library は実装の詳細に依存せずに React コンポーネントをテストすることができるツールセットです。このアプローチはリファクタリングを容易にし、さらにアクセスビリティのベスト・プラクティスへと手向けてくれます。
https://ja.reactjs.org/docs/testing.html
「このアプローチはリファクタリングを容易にし、さらにアクセスビリティのベスト・プラクティスへと手向けてくれます。」 とあるように、Kent の考え方が React 側にも評価されたようです。
テストの方針の結論
Kent の考えにおけるテストの方針を一言でいうと 「ソフトウェアが正しく動くことを保証できるテストを書く」 です。
テストは、テストカバレッジを上げるために書いているのではありません。ソフトウェアが正しく動くことを保証するために書くのです。本番環境にリリース後、そのソフトウェアが「ちゃんと想定通り動くのかどうか」という観点が最も大切であり、それを担保できるテストであるべきだと Kent は主張しています。
トレードオフを理解する
トレードオフとは:
目的に向けて、一方を立てれば他方がまずくなるといった、二つの仕方・在り方の間の関係。
「Oxford Languagesの定義」より
「ソフトウェアが正しく動くことを保証できるテストを書く」という方針は、ぱっと聞くと当たり前のように感じるかも知れません。しかし実際には「ソフトウェアが正しく動くことを保証できるテスト」が全ての面において優れてるわけではありません。
物事には何にでもトレードオフはつきものです。これはこのテストの話についても例外ではありません。そのためこのテストの方針を正しく理解するために、テストの種類を洗い出し、それらのトレードオフを加味した上でこのテスト方針を説明していきます。
テストの種類
Kentのコンセプトを理解するために把握しておくべきテストの種類は以下の4つです。
- E2E
- Integration
- Unit
- Static
後にKentのコンセプトの図で出てくるので、先に簡単に説明しておきます。
E2E
End to End(エンド・トゥー・エンド)の略で E2E (イー・ツー・イー) と読みます。Functional Testと呼ばれたりもします。E2Eテストでは、ブラウザ環境で本格的なユーザーフローをテストします。
ツールとしては、Selenium や Cypress が主流です。
Integration
Integrationテストでは、コンポーネント間の相互作用をテストします。
ツールとしては、テストフレームワークとして Jest や Mocha があり、それを保管するテストユーティリティとして Testing Library や Enzyme があります。
Unit
Unitテストでは、単一のコンポーネントまたは機能を個別にテストします。
ツールは、基本インテグレーションテストと同じものが使われることが多いです。
Static
型や Linter による静的チェックのことを指します。
ツールとしては、TypeScript や ESLint になります。
トレードオフの要素
テストの方針を決めるにあたって、「E2E, Integration, Unit, Static」の4つのテストの種類からトレードオフを考えます。
この4種の間で考慮すべきトレードオフの要素は下記の3つです。
- スピード(テストの実行スピード)
- 工数(テストの実装や保守運用にかかる工数)
- 保証(ソフトウェアが正常に動くことの保証レベル)
「E2E, Integration, Unit, Static」には、それぞれこのトレードオフの要素に大小があります。この4種類のテストとそれぞれのトレードオフの大小の関係性を表したのが、後述するKentの「Testing Trophy」のコンセプトとなります。
Testing Trophyというコンセプト
▼Testing Trophy
参照:https://testingjavascript.com/
上部のイメージはKentの提唱するTesting Trophyというコンセプトを表したものです。この図は4種のテストと「スピード・工数・保証」のトレードオフの関係性を表すものとなっています。これを図に書き込んだ画像が下記です。
参照:https://testingjavascript.com/
この図には3つの矢印が書き込まれています。
左側の矢印:💰工数(コスト)
左端のお金マークのついている矢印は「テストの実装にかかる工数」を指しています。これは上にあるE2Eなどのテストのほうが工数(コスト)がかかるということを指しています。つまりテストの実装工数だけ考えるのであれば、下側にあるStaticやUnitのほうが簡単ということです。
右側の矢印:🐢スピード🚗
右側の亀(🐢)と車(🚗)マークの矢印は、テストの実行スピードを指しています。亀(🐢)は遅い、車(🚗)は速いことを指しています。つまり上側のE2Eほど実行スピードが遅く、テストの実行に時間がかかるということです。テストの実行スピードだけ考えるだけなら、これも下側にあるStaticやUnitの方が速くてよさそうですよね。
じゃあ中央の矢印は?
左右の矢印だけ見ると下側のUnitやStaticのテストを多く書くのがお得そうです。しかしお気づきの通りトレードオフが発生するということは、これ以外の要素も加味する必要があるということです。そして最後の真ん中に存在する矢印こそが、Kentがテストで最も大切な目的の1つとしている「(システムが正しく動くという)保証」を表しています。
中央の矢印:(システムが正しく動くという)保証
この中央の矢印は「システムが正しく動くという保証のレベル」を表しています。上側のテストほど保証レベルが高く、下側ほど保証レベルが低いことを表しています。つまりこの「保証」のみを考えるのであれば最上部にあるE2Eを最も書くべきということになります。
この「保証」の矢印の方向が、左右の「スピード・工数」の方向と逆転していることで、これらの間にトレードオフが発生しているのです。(上側のテストほどより保証はされるけど、スピードと工数が悪くなる)
トレードオフのまとめ
Testing Trophyのトレードオフをまとめると、上にあるテスト(最上部はEnd to End)ほど、テスト実行時間が遅く(☓BAD)、テストの保守運用工数が高く(☓BAD)、システムの正常さの保証レベルが高い(○GOOD)ということになります。
# まとめ
上側にあるテスト(最上部はE2E)ほど、
- テスト実行時間:遅い(☓BAD)
- テストの保守運用工数:高い(☓BAD)
- システムの正常さの保証レベル:高い(○GOOD)
どのテストをどれだけ書けばいいのか
テストの種類とそれらのトレードオフを理解した上で考えないといけないのは「どのテストにどれだけ時間を使うべきか」ということです。実は、これについてはすでに Testing Trophyの体積にて示してくれています。「各テストの書くべき総量の比率」を「トロフィーの体積の比率」で表しています。
TestingTrophyの体積が大きいテストほど多く書くべきということになります。
参照:https://testingjavascript.com/
つまりこのトロフィーの体積を読み取ると「ソフトウェアが正しく動くことを保証できるテスト」を書くには、インテグレーションテストを最も書こうということになります。
Kentは多くの記事でも、「保証 vs スピードと工数」を考慮して最もトレードオフのバランスがよいIntegrationテストを最も書くべきと言っています。その次にE2Eと、Unitを書き、Staticは工数(コスト)がほぼかからないので問答無用で導入しておけばよいと述べています。
ちなみにKentが作った React Testing Library というテストライブラリは、このIntegrationテストに集中できる設計になっています。
(余談)TestingTrophyが生まれた背景
KentがTestingTrophy作る前は、下記の「TestingPyramid」という考え方がありました。
これもTestingTrophyと同じ用に「ピラミッドの面積の比率」が「書くべきテストの総量の比率」を表しています。
しかし、このTestingPyramidでは「ソフトウェアが正しく動くという保証」を加味せず、左側の矢印の「テストの実行スピード」と右側の矢印の「テストの実装工数(コスト)」のみを考慮していたためトレードオフが発生してません。そのためこのTestingPyramidでは下側のユニットテストほど大量に書くべきという考えが導かれていました。
このような「ユニットテスト万歳」という世間の潮流に対し、Kentは「テストの効率より効果に目を向けるべきだ」「ソフトウェアが正しく動くという保証こそ大切なテストを書く目的だ」という考えを広めるためにTestingTrophyというコンセプトを作ったとのことです。
Testing Trophyの概念に従った、テストの方針
テストの方針のコンセプトの説明を終えたので、ここからはそのコンセプトに従うためのより具体的なテストの方針を記載していきます。
- 「実装の詳細」をテストしない
- 外部システムに依存するテストはしない
- 「モック」はできるかぎり少なくする
- 「コードカバレッジ」より「ユースケースカバレッジ」を意識する
1. 「実装の詳細」をテストしない
「実装の詳細」を操作するようなテストを書いてはいけません。なぜならそれは「壊れるべきでないときに壊れるテスト」と「壊れるべきときに壊れないテスト」の両方に繋がってしまうからです。
実装の詳細とは何か
ここでの「実装の詳細」とは、「ユーザーから見えないもの」と考えるとイメージしやすいです。
例えば、HTMLとCSSで表現している「ボタン」や「表示されているテキスト」などは、ユーザーから見えるものですよね。これは「ユーザーから見えるもの」、つまり実装の詳細に該当しないので、テスト上で操作してOKです。(むしろこのようなユーザーから見えるものに対して積極的にテストを書いていきます)
対して、ComponentのStateだったりComponent内のメソッドは、ユーザーからは見えません。これらの「ユーザーから見えない要素 = 実装の詳細」を操作するテストを書いてはいけません。
// ユーザーから見えないもの(実装の詳細→テストで操作したらダメ❌)
- State
- Component内の関数
// ユーザーから見えるもの(テストで操作してOK⭕️)
- UI(表示されるテキストなど)
実装の詳細を書いてはいけない理由
これは前述しましたが、「壊れるべきでないときに壊れるテスト」と「壊れるべきときに壊れないテスト」の両方に繋がるからです。
ちなみに「壊れるべきでないときに壊れること」は「False Negative」と呼ばれ、「壊れるべきときに壊れないこと」は「False Positive」と呼ばれたりします。
つまり「実装の詳細を書いてはいけない理由」を言い直すと「False NegativeとFalse Positiveを生んでしまうから」ということになります。
それぞれ説明します。
False Negative
「実装の詳細をテストすること」は、リファクタの天敵です。なぜならリファクタとはまさしく「実装の詳細を変更すること」だからです。
実装の詳細をテストしているということは、その実装の詳細部分を変更するとテストが壊れるということです。つまりリファクタで簡単に壊れまくります。
リファクタは、ユーザーに対する振る舞いを変えずに実装の詳細だけを変更します。ユーザーに対する振る舞いを変更しないので、テストは壊れるべきではありません。しかし実装の詳細をテストで操作していると、最終的な振る舞いは変化なく想定通りの動きをしているのに、途中の実装の詳細が違うせいでテストが壊れてしまうことが多くあります。
このように、アプリとしては期待通りの動きをしているのに、テストが間違ってエラーを出してしまうことを「False Negative」と呼びます。
FalseNegative こそ、冒頭で述べた「フロントエンドのテストは壊れやすい」というイメージを生んでる犯人です。このFalseNegativeを生まないようにすること、つまり「実装の詳細をテストしないようにすること」こそが「壊れにくいテスト」を書くために重要な考え方になります。
False Positive
本番環境でユーザーがアプリを利用しているとき、アプリは複数のコンポーネントや機能が連動することで成り立っています。そのため、「システムが正しく動くことを保証する」には、全体のコンポーネントや機能が連携された挙動を保証することが不可欠です。
しかし、「実装の詳細」に集中した粒度の小さいテスト(ユニットテストなど)しか行っていないと、各単体のコンポーネントや機能粒度ごとの挙動しか保証できず、コンポーネントや機能同士が連携したときの挙動を担保することができません。
そのため、実装の詳細のテスト(ユニットテストなど)だけだと、実際のアプリの「全体が連携したときの挙動」は壊れているのに、テストが間違って通ってしまうことがあります。
このように、実際のアプリの全体の挙動は壊れているのに、テストが間違って通ってしまうことを「False Positive」と呼びます。
この状況を比喩した面白い動画があります。
expect(umbrellaOpens).toBe(true)
— Erin 🐠 (@erinfranmc) July 10, 2019
tests: 1 passed, 1 total
**all tests passed** pic.twitter.com/p6IKO7KDuy
これが、「実際のアプリの全体の挙動は壊れているのに、テストが間違って通ってしまう」ということです。
このFalse Positveを解消するには、複数のコンポーネントや機能の連携をより高いレベルで保証するテストを書く必要があります。つまり、ユニットテストだけでなく、インテグレーションテストをより中心的に書き、重要な部分はE2Eテストも書こう!という話になります。
FalsePositiveを無くすということは、「テストが壊れるべきときに壊れてくれる」ということです。「壊れるべきときに壊れてくれないテスト」は怖くて信用できません。Kentの目指す「システムが動くという保証のレベル」を上げるには、「実装の詳細をテストするのではなく、全体の繋がったテストにより集中すること」がとても大切なのです。
実装の詳細のテストを避けるためのポイント
さて、ここまでで「実装の詳細をテストするべきでない理由」は説明できました。そこで実装の詳細をテストしないためのポイントを下記にまとめておきます。
- 「ユーザーから見えるもの」という視点でテストする。「ユーザーから見えないもの」はテストしない。
- テストで直接Component内のStateやメソッドをいじらない。
- ユニットテストではなく、インテグレーションテストを最も書くようにする。
- EnzymeのShallow Renderingは使わないようにする。
- Snapshotテストをむやみに使わない
まだ説明してない最後の3点のみ補足しておきます。
ユニットテストではなく、インテグレーションテストを最も書くようにする。
細かく分割されがちなフロントエンドのコンポーネント指向(Reactなど)では、特にユニットテストがそのまま実装の詳細になりがちです。なので基本的にはインテグレーションテストをメインで書くようにしましょう。
Reactでいうとインテグレーションテストはページ全体のテスト(ユーザーの見えるものに関するテスト)が当てはまることが多いです。
【GOODなテストの例⭕️】
ユーザーがボタンをクリックしたときの挙動を再現し、表示がAからBに変わったことをテストする。
(この処理の途中にどんなメソッドが動いているかなどは気にせず、
ユーザーから見える部分のみ操作、テストしている。
そのため表示をA→Bに変える内部メソッドをリファクタで消したとしても、
ユーザーに見える部分が変わらなければテストは壊れない)
【BADなテストの例❌】
表示をAからBに変えるメソッドを直接ユニットテストする。
(ユーザーが見ることのない内部メソッドを直接テストしている。
この場合このメソッドをリファクタするとこのテストが壊れてしまう)
EnzymeのShallow Renderingは使わないようにする
EnzymeのShallow Renderingは一つのコンポーネントのみしか反映せず、直接stateや、インスタンスを生成してメソッドをいじるということが容易なため、結果的に実装の詳細をテストしてしまいがちです。
ちなみにKentは、このshallow
が嫌いすぎて、そもそもshallow
がないテストライブラリを作ればいいじゃん!となり、自らTesting Libraryというライブラリを作っています。
Testing Libraryにはshallow
が無く、代わりに「ユーザーから見えるもの」に関するメソッドが多くなっており、Kentのコンセプトに沿ったテストがとてもしやすくなっています。
Snapshotテストをむやみに使わない
KentはSnapshotテストをむやみに利用すべきではないと言っています。なぜなら、Snapshotテストのレシピがまさに「実装の詳細」そのものだからです。
Reactのリファクタではコンポーネントをよくいじりますが、Snapshotテストはコンポーネントをいじるたびに頻繁に壊れます。
さらにSnapshotテストの悪いところは、エンジニアがSnapshotテストに対してケアレスになっていきやすいことです。なぜならSnapshotテストが毎回壊れることによって、毎回Snapshotテストも変更する必要が出るからです。
さらに悪いところは、このケアレスになったSnapshotテストがあることによって、すでに何も保証されなくなったSnapshotテストであったとしても、それがあたかも「システムの正常性を保証してくれている」という幻想を抱いてしまいやすいことです。
このことからKentはSnapshotテストに対して「almost worse than no tests」(何もテストしないより悪いこともある)とさえ言っています。
ただこのようなデメリットを持ちながらも、Snapshotテストが有効となり得るシチュエーションが一部あることも認めています。
(例:開発者ツールにて出力するログの内容を保証するテスト、Babelのプラグインのテスト、CSS in JSのCSSの状態を保証するテスト、など)
また、Snapshotテストを利用する場合は、Snapshotサイズをできるだけ小さくするなど、デメリットを軽減するためのアドバイスも説明しています。
まとめると、Snapshotテストのデメリットを理解せずに、むやみに利用するのは避けたほうがいいでしょう。使う場合はデメリットを理解した上で、そのデメリットに見合うメリットがあるのかを考慮した上で使いましょう。そしてその場合は、デメリットをできるだけ軽減する方法も意識する必要があるでしょう。
ちなみに、React公式ドキュメント上でも、下記のように同様の指摘やアドバイスがされています。
一般的には、スナップショットを使うよりもより個別的なアサーションを行う方がベターです。このようなテストは実装の詳細を含んでいるために壊れやすく、チームはスナップショットが壊れても気にならないようになってしまうかもしれません。選択的に一部の子コンポーネントをモックすることで、スナップショットのサイズを減らし、コードレビューで読みやすく保つことができるようになります。
参考: スナップショットテスト
「実装の詳細をテストしない」のまとめ
「実装の詳細をテストしない」ことに関する内容が多くなりましたが、この「実装の詳細をテストしない」というのは、Kentの考えにおいてとても中心的なものになります。ここさえできればこの記事のタイトルである「壊れにくいテスト」がかなり書きやすくなります。ぜひ実践で活用してみてください。
「実装の詳細をテストしない」の説明が少し長くなったので迷子にならないために、残りの内容を下記に再掲します。ここからは「Testing Trophyの概念に従った、テストの方針」の残りの3つを説明していきます。
Testing Trophyの概念に従った、テストの方針
1. 「実装の詳細」をテストしない
2. 外部システムに依存するテストはしない**【←次ここ】**
3. 「モック」はできるかぎり少なくする
4. 「コードカバレッジ」より「ユースケースカバレッジ」を意識する
2. 外部システムに依存するテストはしない
外部システムと通信する部分はモックで置き換えて、できるだけ外部システムに依存しないテストを書きましょう。
外部システムへの通信部分はモックで置き換え、そのモックされたメソッドの「呼び出し回数」と「呼び出し時の引数」をテストするようにしましょう。
3. 「モック」はできるかぎり少なくする
モックをするほどテストを偽装しているので、テストに対する「システムが正しく動くという保証レベル」が下がります。
モックの利用は前述した「外部システムに依存する部分」などのように、必要な場面に絞って使うようにしましょう。
4.「コードカバレッジ」より「ユースケースカバレッジ」を意識する
コードカバレッジの目標値を超えるためにテストを書くのではなく、どれだけのユースケースをカバレッジしているかという「ユースケースカバレッジ」を意識して書こうとのことです。
ユースケースをテストでカバーすることこそが、「システムが正しく動くという保証レベル」を上げることに繋がるのです。
コードカバレッジはあくまでそのための間接的な参考値でしかないことを忘れないようにしましょう。
最後に
Kentの考え方をざっとまとめてみましたが、Kentのブログにはもっと様々な視点からテストに関する考えを書いており読むだけでとても勉強になります。
ぜひ一読してみてください。
▼Kentのブログ
https://kentcdodds.com/blog