ユニットテストを書こう!
ソフトウェアエンジニアにとって、ユニットテストは重要です。僕はなるべくユニットテストを書くようにしており、ソフトウェアエンジニアはもっとユニットテストを書くべきだ、と考えています。ここで言及している「ユニットテスト」は、単なる「テストコードによる自動化」全体を指すのではなく、「テストから見えてくるグーグルのソフトウェア開発」で登場した用語である「Sテスト」を指します。
「テストから見えてくるグーグルのソフトウェア開発」では、テストコードが対象とするプロダクションコード(製品コード)の規模、S、M、Lとサイズごとに分類しています。
「Sテスト」とは、テスト対象のクラスのみを対象にしたテストを行うことを目的としています。テスト対象以外のクラスの処理は、積極的にモックを多用することで、テスト対象のクラスの振る舞いを確認します。
Sテストは主に品質向上に寄与すると「テストから見えてくるグーグルのソフトウェア開発」に書かれています。僕はSサイズのユニットテストを書くのが好きです。ユニットテストを書くことで何がどう嬉しいのか、それを共有するため、それが何故なのか、自分なりに考えてみました。
対象読者
- ユニットテストを書いたことがない人
- ユニットテストの効果がよくわからない人
- 効果的なユニットテストを書くにはどうすればよいか学びたい人
Q. なぜユニットテストを書くとプロダクションコードの品質が上がるのか?
ユニットテストを書いたことがない人や、ユニットテストの効果がよくわからない人が一番気にするのは、ユニットテストに投資した時間が、どのようにチームの利益になるのか、という部分でしょう。
ではユニットテストを書くと、どんな「品質」が上がるのでしょうか?
ユニットテストがもたらす品質を箇条書きにすると
- [理解のしやすさ] プロダクションコードに書かれた処理がユニットテストに例示されるため、プロダクションコードの理解を助ける
- [影響範囲特定の容易さ] プロダクションコードの変更時、既存と振る舞いが変わる箇所はテストが失敗する。そのため、変更が影響を及ぼす範囲がわかる
- [利用しやすさ] ユニットテストを書くときは、APIの利用者になるため、APIの利用者から見て利用しやすいコードを目指すモチベーションを産みやすい
- [変更しやすさ] ユニットテストを書くと、コードの振る舞いがテストコードに明示されるため、振る舞いを予測可能な範囲が増える
- [高凝集] 複雑なテストを書きたくないため、単機能に絞ったわかりやすいコードを目指すモチベーションを産みやすい
- [低結合] 多数のテストに影響を及ぼさないクラスを目指すモチベーションを産みやすい
と言った項目を挙げられます。それぞれを深堀りしてみましょう。
A1. プロダクションコードに書かれた処理がユニットテストに例示されるため、プロダクションコードの理解を助ける
ユニットテストを書くと、大体下記の事が明文化されます。
事前条件
プロダクションコードのクラスの利用時に、どういったオブジェクトが設定されていないといけないのか
例外が発生する条件
事前条件が整っていない場合や、クラスが引き受けられない引数が渡ってきた時、どういった例外がおきるのか。また、例外と扱わない場合はどう処理されるのか。
どういう入力をするとどういう出力になるのか
プロダクションコードがどのように入力を受け付け、処理をした結果どのように出力されるのか?はたまたコラボレータに処理を委譲されるのか?
逆に言えば、テストケースを記述する場面では、これらの事をわかりやすくなるように、関心毎にケースを分けて記述すべきです。
テストケースも、関心毎に分けられるように、書くべきケースを決めておくと良いでしょう。
- 事前条件を満たしていない時の振る舞いを確認するテスト
- 引数にnullを渡すなど、異常値を渡した時の振る舞いを確認するテスト
- 0件の場合、最小値の場合、最小値-1の場合、最大値の場合、最大値+1の場合など、境界値や代表値でどう出力するのか確認するテスト
- 正常値のパターンを確認するテスト
- 実装を進める中で例外のハンドリングが増えた場合は、それを確認するテスト
チームでどういうテストをするのか話し、こんな感じのリストを作ってみましょう。メンバーが作成するテストケースは、人それぞれ観点が異なるため、ばらける事が多いです。適宜議論を行い、リストを更新していくと人それぞれ異なっていた観点を整理することができるのでオススメです。
A2. プロダクションコードの変更時、既存と振る舞いが変わる箇所はテストが失敗する。そのため、変更が影響を及ぼす範囲がわかる
「テストが失敗する」というフィードバックは、とても大切です。ユニットテストを整備しておくと、プロダクションコードの変更で意図しない既存の振る舞いを変える変更が行われた時、検証しているテストが失敗するはずです。テストが失敗する、という事実には、2つの意味があります。
- もともと意図していた仕様を壊した
- 変更時に意図していない仕様変更が発生した
1つ目の意味は、テストのセーフティーネットとしての意味です。自動テストに求められる本来の役割なので、違和感はありません。2つ目の意味は、失敗したテストケースを分析すると、実はそのテストケースの仕様を意図せずに変更していた、という事です。実装がそれぞれのテストケース毎に独立していれば、影響することがありませんが、それは現実にはなかなか実現できないことではないでしょうか?
メソッドの仕様をJavaDoc等のコメントに書いていると思います。意図しない仕様変更が行われると、嘘の情報を伝えてしまうことになり、コメントがないよりも悪い状態にあると言えます。
仕様を伝えるためにユニットテストを使うと、上記のように、仕様を間違って伝える可能性がコメントのみの場合よりも少なくなります。記述されている仕様の内容をより正しく解釈できるので、勘違いを減らすことができるのです。
A3. テストコードを書くときは、APIの利用者になるため、APIの利用者から見て利用しやすいコードを目指すモチベーションを産みやすい
特にTDDをしながらテストコードを書く場合、使いやすいAPIを意識しながら実装することになるでしょう。実装中のAPIの初めてのユーザーは、プロダクションコードの書き手になるのですから。
APIのユーザーになってみると、利用するのにあれこれ手順が必要な実装にはしたくなくなるはずです。DIコンテナを利用しているのであれば、フィールドのセットアップ等、ユーザーがしなくてもいい部分は多々あるでしょう。しかし、あるメソッドを呼び出すのに、別のメソッドを予め呼び出さなければならない…。というようなメソッドの呼び出しに制限があるようなAPIは設計したくなくなるでしょう、面倒なので。
A4. テストケースを書くと、コードの振る舞いが明示されるため、振る舞いを予測可能な範囲が増える
A1.にも書きましたが、ユニットテストを実装すると、「ある入力をした時、どういう出力になるのか」という、「実際の振る舞いという事実」を積み上げることができます。ソフトウェアは、同じ状態で同じ入力であれば、必ず同じ出力をします。(宇宙線などで状態が変わってしまった場合を除く)そのため、「実際にどう振舞っているのか」という事実を明らかにすれば、「どう動くのか予測できず、わからない」という不安を減らすことができます。
もし、テストケースを実装しているうちに「この入力だとどう出力されるのだろう?」と言った「どう動くのか予測できず、わからない」という「不安」を感じる部分が出てくれば、その不安を解消するように実装を進めるでしょう。
「振る舞いを予測可能な範囲が増える」としたのは、言い換えると「よくわからないけど動いている」という部分を減らすことができる、ということです。「仕様化テスト」という手法は、ユニットテストを使って、振る舞いが予測できない部分を明らかにする手法です。振る舞いが予測できず、よくわからない部分は、ユニットテストを書いて振る舞いを予測可能な部分を増やしましょう。
A5. 複雑なテストを書きたくないため、単機能に絞ったわかりやすいコードを目指すモチベーションを産みやすい
ユニットテストを書くには、入力と出力結果を考えなければなりません。最初から色々な組み合わせを同時に考えるのは大変です。なので、機能をしぼり、単純にし、それがうまく動作しているのか確認して、出来上がったクラスを組み合わせて複雑な機能を実現した方が簡単に考えられます。
例えば「スクリーンショットを取った後、ダイアログを表示し、ファイル形式を選び、ファイル名を入力、その後「出力」を押すと指定したファイルに出力する」という処理を考えた時、全ての操作を一気にテストせずに、下記のように機能を分割して考えた方が考えやすいのではないでしょうか?
- スクリーンショットを取る
- ファイル情報を入力するダイアログの表示/操作
- ファイル名とファイル形式のチェック
- 指定したファイル名で画像を出力
それぞれ、別々のクラスに分け、異常なパターンも考えつつ、テストケースを整備し、組み合わせると、最初から全ての機能を一気につなげて実装するよりは時間がかかるかもしれませんが、出来上がるものの質は高くなるでしょう。
A6. 多数のテストに影響を及ぼさないクラスを目指すモチベーションを産みやすい
プロダクションコードをちょっとだけ変更しただけで、たくさんのテストが失敗するような状況は、確認しなければならないテストがとても多く、修正も大変です。そうならないよう、プロダクションコードのモデルを考え、メソッドを呼び出すように工夫すべきです。しかし、ユニットテストを作らず、ユーティリティクラスを作るような、手続き的な設計をしていると、そのクラスにいろいろなロジックが詰め込められがちです。なぜなら、ユーティリティクラスにいろいろな処理が揃えられている方が、ロジックを探す手間を省けるので、便利に感じるからです。
ユニットテストを書くようになると、一つのクラスにたくさんの処理を書くことを避けるようになります。というのも、たくさんの処理がひとつのクラスにあると、対になるテストクラスも相当大きくなり、実装するのが大変になります。総じて大きなクラスというのは、修正が大変なため、テストケースもできるだけ減らすように実装を進めるようになるでしょう。
たくさんのテストを失敗させてしまう状態というのは、そのクラスに依存するプロダクションコードがたくさんある、という状態です。たくさんのクラスが依存してしまう状態は、変更が影響しやすい、という意味で変更に弱い構造です。そうならないように設計しましょう。
ユニットテストを書く時によく使う手法
モックオブジェクト
事前条件を整えるため、オブジェクトを組み立てようとしたら、そのオブジェクトの組み立てをするのにたくさんオブジェクトを作成しなければならなくて、ぐったり。みたいな経験、ありませんか?
モックオブジェクトはそういう場面で活用します。モックオブジェクトは、手書きで実装することももちろんできますが、作成は面倒なため、ライブラリもたくさんあります。モックオブジェクトを作成できるライブラリには、大体下記の機能を持ってることが多いでしょう。
- 既存のクラス、インタフェースを引数に指定すると、同一のインタフェースを持ったモックオブジェクトを作成する。
- 作成されたモックオブジェクトは、メソッドの呼び出し方を指定することで指定した値を返したり、例外を投げたりできる。
- モックオブジェクトに渡された引数を監視し、検証に利用できる。
- モックオブジェクトのメソッドが呼び出されたか/呼び出されなかったかを後で検証できる
これらの機能を使って事前条件を満たしたり、他のオブジェクトとのコラボレーションをテストします。
モックオブジェクトを利用する場合、気をつけなければならない事
モックオブジェクトは実装ではないので、モックオブジェクトが返す値と実装の返す値が乖離することがあり得ます。モックオブジェクトのインタフェースが安定していれば、乖離することを減らせます。そうなるように設計を進めましょう。
よく使われるモックライブラリ
- Java : Mockito
- Ruby : rspec-mock
- JavaScript : sinon.js
TDD
テストコードを書く手法には、TDD(テスト駆動開発)というものがあります。TDDをご存じない方に簡単に説明すると、
- プロダクションコードを書く前に失敗するテストケースを1つだけテストコードを書く
- 書かれているテストケースを全て成功させるプロダクションコードを書く
- 全てのテストが成功したら、リファクタリングを行い、コードを綺麗にする
- リファクタリングをしてコードが綺麗になったら 1. に戻る
というものです。
テストケースを小さなゴールとして、少しずつ、インクリメンタルに機能を追加するのがTDDです。いつまでこのサイクルを回すべきか、というと、
- プロダクションコードが担うべき責務が実装されていることを確認できている
- プロダクションコードの中に「不安」がない
という状態を達成した時でしょう。
さて、ここで述べた「不安」とはなんでしょう?あまり明言しているものを目にしたことがないので、自分なりの解釈で言うと、「どう振る舞うのかわからないコードがある」というのが「不安」です。
新人のエンジニアにお願いした仕事が、仮にGoogle先生に質問してコピペしただけのコードだったとしましょう。不安になりませんか?どれだけ考慮されて書かれたコードなのか、全くわかりません。依頼したとおりに動いていたとしても、違う操作をするとすぐにバグが見つかるかもしれません。
テストコードがあれば、どういう観点で検討したか形になって残ります。どれだけ不安に対し、どれくらい検討したか、その足跡が残ります。だから、エンジニアが不安に感じたことは全てテストコードに残すべきです。
TDDのメリットとデメリットをざっくりまとめると、
メリット
- 一度に考える範囲を制限できる
- 自然と既存のテストを壊す変更を検知する、自動テストによるセーフティーネットがプロダクションコードと同時期にできる
- 利用する側からの視点でプロダクションコードのAPIを設計できる
デメリット
- アーキテクチャが個別に最適してしまい、俯瞰した時の構造がいびつになることがある
- おぼろげながらもソフトウェアで解決したい問題領域のモデルがわかっていなければ実装できない
- 教条主義的にTDDを実践すると、小さなクラスが過剰に生まれてしまう
と言えます。
Ruby on Railsの開発者のDHHが「TDDは死んだ!テスティングに栄光あれ!」と言うポストをしました。もうずっとTDDにこだわらず、開発者を続けているから投じた一石ですが、DHHも以前はTDDを実践したこともあるそうです。TDDを実践したからこそ、うまくテスティングできるようになった、とも言っています。
デメリットに書いたとおり、TDDを適用すべきではない場面もあります。ある程度ソフトウェア開発に慣れるまでは、何を作るべきなのかを強く意識する意味でTDDをすることはとてもよいことでしょう。
仕様化テストと学習テスト
仕様化テストとは、既存のプロダクションコードの振る舞いを確認するために、テストコードを書くことです。プロダクションコードを読み、理解したことをテストコードを使って確認します。仕様化テストをリグレッションテスト(回帰テスト)に追加することで、プロダクションコードを変更した時にその仕様が壊れていないか確認できます。
対して学習テストとは、外部のライブラリの振る舞いを確認するために、テストコードを書くことです。リグレッションテスト(回帰テスト)に学習テストを含めておくと、ライブラリのバージョンを更新した時に、変更された振る舞いを見つけられます。
どちらも、「既存のコードに対しテストコードを書き、既存のコードの振る舞いを学ぶ」という意味では同じです。違いは自分たちでコントロールできるソースコードか、そうじゃないか、という点くらいです。
メリット
- コードの振る舞いを正確に確認できる(思い違いが少ない)
- 依存する他のオブジェクトに何があるかがわかる
デメリット
- 確認したい振る舞いを考えないと費用対効果が薄くなる