Webフロントエンドのテストに関して、数年前に比べるとかなり手法が確立されました。そんな中、やはりテストそのものに対する考え方というのはずっと役に立っていると感じます。
この記事では、何をどのようにテストするのか、テストで何を目指すのか。そのようなことを書いてみたいと思います。
テストは品質をあげない
テストはソフトウェアの品質を測る指標にはなりますが、テストそれ自体は品質を上げるわけではありません。
例えば、むやみにカバレッジをあげることはコストを増やしますし、あまり意味もないでしょう。そのため、テストの目的や種別、それぞれで担保すべき要素を理解しておくことが重要だと考えます。
テストの役割とは
テストをすることで、結果的には品質に貢献しますが、あくまでひとつの要素として捉えておくことが必要です。「品質」は思うより大きな言葉で、そもそもの技術的なアーキテクチャはもちろん、技術以外にも多くのステークホルダーが関わっています。
ソフトウェアにおけるテストの役割は、ひとことでいうと、信頼できるソフトウェアをつくることだと考えます。それはむしろ、開発者視点での役割が大きいです。
例えば、以下のようなことが達成できます。
- 機能追加、変更、削除時に既存の機能を壊さない可能性を上げることができる
- 開発中に予期せぬエラーを見つけやすくなり、時間を節約できる
- 例えエラーが見つからないとしても、コードの観点で機能が担保されていれば、信頼できるコードをデプロイできる
- テストファーストのプロセスでは、テストケース(仕様)から実装を考えることができ、設計がしやすくなる
テストの種別とフロントエンド
テスト種別の整理をしながら、フロントエンドのテストについて考えてみます。
- テストレベル
- ユニットテスト
- 結合テスト
- システムテスト
- 目的別テスト
- リグレッションテスト
- UIテスト
- テスト技法
- ブラックボックステスト
- ホワイトボックステスト
それぞれについて説明します。
ユニットテスト
小さなプログラムを「単体」でテストする手法をいわゆるユニットテストと呼びます。パッケージや少し大きなモジュール単位で実行するものを「コンポーネントテスト」と呼んだりもしますが、大差はないと考えて良いでしょう。
ユニットテストは、基本的には100%に近いカバレッジで実装されることが望ましいです。最も実装/実行コストが少ないですし、書けばとりあえずメリットを出しやすいのがこのテストです。
注意点としては、できる限り小さくテストすること、高速に実行できることを守るという点です。これを守ることでエラーの特定時間を短縮しやすくなります。(ひとつのテストの実行時間は1/10秒以下であるべき、としている書籍もあります)。
フロントエンドでは、JestやMochaでロジックをテストするようなケースがユニットテストに当たります。これは、コンポーネントの振る舞いをテストするものや、Jestのsnapshotテストのようなものも含まれると考えます。
結合テスト
いくつかのクラスや関数を組み合わせ、意味のある塊をテストする手法です。
このとき、使用するクラスのロジックについては気にしないことがポイントです。例えば、「FacadeパターンやProxyパターンの実装の入出力だけをテストしたい」などが対象として思い浮かびます。「プライベートメソッドをテストしない」理由と少し似ていますね。
あるいは、フロントエンドでは、UIも含めたコンポーネントとして、ブラウザを使ってテストするケースも多いでしょう。
システムテスト
すべての要素(例えばバックエンドとの通信・ハードウェアなども)をつなぎ合わせてテストすることをいいます。(結合テストやE2Eテストということもありますが、この記事では区別するためにこう呼ぶことにします)。
いわゆるE2Eテストをフロントエンドで実装する際も、それが結合テストなのかシステムテストなのかはしっかり区別する必要がありそうです。
リグレッションテスト
動作を担保するために実装するテストです。複雑なロジックがないコードでも、仕様を担保し、予期せぬ変更がないことを保証するために実装します。
UIテスト
見た目に関するテストです。
フロントエンドでわかりやすいものだと、Visual Regression Testなどがあげられます。UIの予期せぬ変更を検知するのが目的で、名前の通りUIに関するリグレッションテストと位置づけることができます。
また、StorybookなどによるUIドキュメントも、Addonを駆使すれば、ある種のUIテストといえるでしょう。ただし、Storybookをユニットテスト代わりに考えるのは危険だと考えます。ある程度複雑な振る舞いをテストする際には、別途ユニットテストを実装する方が好ましいです。
ホワイトボックステスト / ブラックボックステスト
ホワイトボックステストとは、機能のロジックや入出力をベースにテストする手法です。
私達が普段書いているユニットテストは、ホワイトボックステストに含まれることが多いです。例えば、クラスのメソッドの振る舞いや、関数の返り値をテストする、などです。網羅性などを意識するのも多くはホワイトボックステストになるでしょう。
逆にブラックボックステストとは、機能の実際の振る舞いをベースにテストする手法です。内部的なロジックを気にせずに振る舞いのみに焦点を当ててテストをします。
テスト種別でいうと、システムテストはブラックボックテストを行うことが多いです。内部ロジックについてはユニットテストで保証されている前提で、実際の振る舞いを大きくテストするためです。
また、結合テストも、複数のロジックをつなぎこんだ際の振る舞いをテストするという見方もできるので、ブラックボックステストをしているともいえると考えます。そして同時に、結合観点のロジックに関してはホワイトボックステストしているとも捉えることができます。
この2つの考え方は「何をテストするべきか」「関数の責務がどこにあるか」ということを検討するときに有効です。
リファクタリング
テストをする上でリファクタリングを意識することは重要です。
Working Effectively with Legacy Code
という書籍から、2つの重要なコンセプトを紹介します。
Seam (接合部)
Seamとは「その場所を直接編集しなくても、プログラムの振る舞いを変えることができる場所」のことです。
例えば、DIをして振る舞いを変えられるようにクラスを設計する、メソッドを露出してオーバーライドできるようにしておく、などです。このように依存性を外部から与えることができると、テストがしやすくなりますし、クラスの責務も小さくすることができます。
Sprout Class
Sprout Classは、既存のテストされていないコードに機能追加する際に有効です。
手順としては以下のようになります。
- 変更箇所を把握
- 既存のコード(例えば巨大なクラスなど)から、該当箇所のみを切り出す
- 新しいクラスをつくり、
2
のコードを移植する -
3
のテストを書く -
3
を自由にリファクタリング! -
2
からは3
を呼び出すようにする
小さなコードを切り出し、追加・変更分だけでもテストしてゆく手法ですね。
この手法で重要なことは以下2点です。
- ヤバいコードは悪化させないこと
- タッチした箇所から少しでも信頼度を上げてゆくこと
この考え方はリファクタリングの基本的な考え方として有効だと思います。
テストとDRY
この記事で書いたことを踏まえると、テストにおいてはDRYにこだわりすぎる必要はありません。
コードの信頼度をあげられればテストとして意味がありますし、無理に設計してコストを上げることも不毛です。
また、DRYにすることで仕様が隠蔽されてわかりにくくなり、開発者の自己満足となってしまうテストはありがちです。そうではなく、テストケースやコードを見た時に、何が担保されているのかがひとめでわかる方がいいです。
よくないコードだけ例としてあげておきます。
test('', () => {
setUpEverything() // ←
expect(true).toBe(true)
})
おまけ(どのテストをどのくらい書くべきか)
初期~のプロジェクトでは、ユニットテストをベースとしたピラミッドのようなイメージで実装します。
- 基本的にはユニットテストで100%担保する
- 結合テストをいれた方がいいところはいれる
- 上記でどうしても担保できない、あるいは、どうしてもつなぎ込んで担保したい受け入れ条件をシステムテストする
プロジェクトが巨大になるほど、システムテストにかける時間が増えていくというデータがあります。
これは、大きいプロジェクトほど振る舞いが増え、機能同士の結合度も上がるため、システムテストで担保すべきことが多くなるのではないかと予想しています。また、システムテストに時間を配分することで、無駄な単体テストを実装しないという選択はよくありそうです(このへん詳しい方いたら教えて下さい)。
まとめ
安心してフロントエンドをつくるためにテストは重要だと思います。単なる方法論に捉われず、価値のある設計を目指し日々精進したいです。