ユニットテスト
ご存知の通り、コンピュータプログラムを実行するあらゆるデバイスにはソフトウェアのバグが含まれている。ソフトウェアのバグは、最も単純なデバイスから最も複雑なデバイスまで、あらゆるコンピュータデバイスに存在する。ソフトウェアのバグのデバッグと修正は、プログラマーの日常業務の中でも特に好ましくない作業のひとつだ。
バグの原因
ソフトウェアのバグが発生する理由は多岐にわたる。以下は、プログラムの設計段階から実際のコーディングに至るまでの、不完全なリストだ。
- プログラムの要件や仕様が明確でない場合がある。プログラムが実際に何を行うべきかが、設計段階で明確でない場合がある。
- プログラマーがプログラムの要件の一部を誤解している可能性がある。
- プログラミング言語の表現力が不十分である。人間の言語でもネイティブスピーカーの間で混乱が生じることを考えると、プログラミング言語の不自然な構文や規則も間違いの原因になる可能性がある。
- プログラマーの仮定が間違っている場合がある。例えば、プログラマーは3.14がπを表現するのに十分な精度であると仮定しているかもしれない。
- プログラマが、あるトピックについて誤った情報を持っている、あるいはまったく情報を持っていない場合。例えば、プログラマは、特定の論理式で浮動小数点変数を使用すると信頼性が低下することを知らないかもしれない。
- プログラムが予期しない状況に陥る可能性がある。例えば、
foreach
ループでディレクトリ内のファイルを使用している間に、そのディレクトリ内のファイルの一部が削除されたり、名前が変更されたりする場合がある。 - プログラマが愚かなミスをする場合もある。例えば、変数の名前が誤って入力され、別の変数の名前と偶然一致する場合がある。
- など
残念ながら、プログラムが常に正しく動作することを保証するソフトウェア開発手法はまだ存在しない。これは、10年ごとに有望な解決策が登場する、ソフトウェア工学のホットなテーマだ。
バグの発見
ソフトウェアのバグは、プログラムのライフサイクルのさまざまな段階で、さまざまな種類のツールや人々によって発見される。以下は、バグが発見される可能性のある時期を、最も早いものから遅いものの順に一部挙げたものだ。
- プログラムの記述時
- プログラマーによって
- ペアプログラミング中に別のプログラマーによって
- コンパイラによるコンパイラメッセージを通じて
- プログラムのビルドの一部としてユニットテストによって
- コードレビュー時
- コンパイル時にコードを分析するツールによって
- コードレビュー中に他のプログラマーによって
- プログラムを実行するとき
- 実行時にプログラムの実行を分析するツール(例:valgrind)によって
- QAテスト中に、
assert
チェックの失敗またはプログラムの動作の観察により - プログラムのリリース前にベータユーザーによって
- プログラムのリリース後にエンドユーザーによって
バグをできるだけ早く検出することで、金銭的損失、時間の損失、場合によっては人命の損失も減らすことができる。さらに、エンドユーザーによって発見されたバグの原因を特定することは、開発段階で早期に発見されたバグの原因を特定するよりも困難だ。
バグ検出のためのユニットテスト
プログラムはプログラマーによって記述され、Dはコンパイル言語であるため、バグを発見するのは常にプログラマーとコンパイラだ。この2つを除けば、バグを最も早く、そしてその理由もあって最も効果的に発見する方法は、ユニットテストだ。
ユニットテストは現代のプログラミングにおいて不可欠な要素だ。コーディングエラーを削減する最も効果的な方法だ。一部の開発手法では、ユニットテストで保護されていないコードはバグのあるコードとみなされる。
残念ながら、逆は真ではない:ユニットテストはコードがバグフリーであることを保証しない。非常に効果的ではあるが、バグのリスクを軽減するだけだ。
また、ユニットテストにより、コードのリファクタリング(つまり、コードの改善)を簡単かつ自信を持って行うことができる。そうしないと、プログラムに新しい機能を追加する際に、既存の機能の一部を誤って破壊してしまうことがよくある。この種のバグは、回帰バグと呼ばれる。ユニットテストを行わない場合、回帰バグは、次のリリースのQAテストの段階で発見されることもあれば、さらに悪い場合には、エンドユーザーによって発見されることもある。
回帰のリスクはプログラマーがコードのリファクタリングを避ける原因となり、変数の名前を修正するといった簡単な改善すら行えなくなることがある。これにより、コードが次第に維持困難な状態になる"コードの劣化"が発生する。例えば、複数の場所から呼び出すために、一部のコード行を新しく定義した関数に移動したほうがいい場合でも、回帰を恐れて、プログラマは既存のコード行を他の場所にコピー&ペーストしてしまい、コードの重複という問題が発生する。
"壊れていないなら直すな"といったフレーズは、回帰への恐怖と関連している。これらのガイドラインは知恵を伝えているように見えるが、実際にはコードを徐々に劣化させ、手をつけられない混乱状態に陥らせる原因となる。
現代のプログラミングは、このような"知恵"を拒否する。逆に、バグの原因にならないように、コードは"容赦なくリファクタリング"されるべきだ。この現代的なアプローチの最も強力なツールがユニットテストだ。
ユニットテストは、コードの最小単位を独立してテストするプロセスだ。コードの単位が独立してテストされると、その単位を使用する上位のコードにバグがある可能性が低くなる。各部分が正しく動作すれば、全体も正しく動作する可能性が高くなる。
ユニットテストは、他の言語ではライブラリソリューションとして提供されている(JUnit、CppUnit、Unittest++など)。Dでは、ユニットテストは言語のコア機能だ。ユニットテストには、ライブラリソリューションと言語機能、どちらが適しているかは議論の余地がある。Dは、ユニットテストライブラリに一般的に見られる機能の一部を提供していないため、ライブラリソリューションも検討する価値があるかもしれない。
Dのユニットテスト機能は、unittest
ブロックにassert
チェックを挿入するだけのシンプルなものだ。
ユニットテストの有効化
ユニットテストはプログラムの実行の一部ではない。明示的に要求された開発中にのみ有効化すべきだ。
ユニットテストを有効にするdmd
コンパイラスイッチは‑unittest
だ。
プログラムがdeneme.d
という単一のソースファイルに記述されている場合、そのユニットテストは次のコマンドで有効化できる。
‑unittest
スイッチでビルドされたプログラムを実行すると、まずユニットテストブロックが実行される。すべてのユニットテストが成功した場合にのみ、プログラムの実行はmain()
で継続される。
unittest
ブロック
ユニットテストを含むコード行は、unittest
ブロック内に記述される。これらのブロックは、ユニットテストを含むこと以外のプログラム上の意味はない:
unittest
ブロックは任意の場所に配置できるが、テスト対象のコードの直後に定義すると便利だ。
例として、指定された数値を"1st"、"2nd"などの順序形式で返す関数をテストしよう。この関数のunittest
ブロックには、関数の戻り値を期待値と比較するassert
文を記述するだけでよい。次の関数は、4つの異なる期待結果でテストされている。
上記の4つのテストでは、関数を4回呼び出し、返された値を期待値と比較することで、少なくとも1、2、3、10の値に対して関数が正しく動作することをテストしている。
ユニットテストはassert
のチェックに基づいているが、unittest
ブロックには任意のDコードを含めることができる。これにより、テストを実際に開始する前に準備を行ったり、テストに必要なその他のサポートコードを記述したりすることができる。例えば、次のブロックでは、コードの重複を削減するために、まず変数を定義している。
上記の3つのassert
チェックは、toFront()
が仕様通りに動作することをテストしている。
これらの例が示すように、ユニットテストは、特定の関数をどのように呼び出すべきかの例としても役立つ。通常、ユニットテストを読むだけで、その関数の機能について簡単に理解することができる。
例外のテスト
特定の条件下でスローすべき、あるいはスローしてはならない例外の型について、コードをテストすることはよくある。std.exception
モジュールには、例外のテストに役立つ2つの関数が含まれている。
assertThrown
: 式から特定の例外型がスローされることを確認するassertNotThrown
: 式から特定の例外型がスローされないことを確認する
例えば、2つのスライスパラメータの長さが等しく、空のスライスでも動作することを要求する関数は、次のテストでテストできる。
通常、assertThrown
は、その例外の実際の型に関係なく、何らかの型の例外がスローされることを保証する。必要に応じて、特定の例外型に対してテストすることもできる。同様に、assertNotThrown
は、例外がまったくスローされないことを保証するが、特定の例外型がスローされないことをテストするように指示することもできる。特定の例外型は、これらの関数のテンプレートパラメータとして指定する。
テンプレートについては後の章で説明する。
これらの関数の主な目的は、コードをより簡潔で読みやすくすることだ。例えば、次のassertThrown
行は、その下の長いコードと同等だ。
テスト駆動開発
テスト駆動開発(TDD)は、機能を実装する前にユニットテストを書くことを規定したソフトウェア開発手法だ。TDDでは、ユニットテストに重点が置かれる。コーディングは、テストを合格させるための二次的な活動だ。
TDDに従って、上記のordinal()
関数は、まず意図的に間違って実装することができる。
この関数は明らかに間違っているが、次のステップでは、ユニットテストを実行して、この関数の問題が実際にテストで検出されるかどうかを確認する。
関数は、失敗を確認した後、テストに合格するためだけに実装すべきだ。以下は、テストに合格する実装の1つだ。
上記の実装はユニットテストに合格しているので、ordinal()
関数が正しいと信頼する理由がある。テストによって保証されているため、この関数の実装は、自信を持ってさまざまな方法で変更することができる。
バグ修正前のユニットテスト
ユニットテストは万能薬ではない。バグは必ず存在する。プログラムを実行中にバグが発見された場合、ユニットテストが不十分であったことを示す兆候と見なすことができる。そのため、まずバグを再現するユニットテストを書き、その後バグを修正して新しいテストを通過させる方が望ましい。
dstring
で指定された数字の序数形式のスペルを返す次の関数を見てみよう。
この関数は、例外的なスペルも処理し、そのためのユニットテストも備わっている。しかし、この関数にはまだ発見されていないバグがある。
プログラムの出力におけるスペルエラーは、ordinalSpelled()
のバグによるもので、そのユニットテストでは検出されていない:
彼はレースで20位でゴールした。
この関数は、yで終わる数字のスペルを正しく生成していないことは簡単にわかるが、TDDでは、実際にバグを修正する前に、まずバグを再現するユニットテストを作成することが義務付けられている。
テストを改善することで、開発中にこの関数のバグが検出されるようになった。
関数は、その時点で初めて修正すべきだ。
演習
TDDに従ってtoFront()
を実装しよう。以下の意図的に不完全な実装から始めてみよう。ユニットテストが失敗することを確認し、テストに合格する実装を提供しよう。