もうひとつのTDD

5年前の今頃は、なにやらTDD熱かったらしい。

TDDには全く何も取り組んでいない身なのだが、しかし傍から見聞きしているだけでも、TDDには参考となる面が色々とある。

ただし、それらの「参考となる面」は、TDDそのものというよりも、TDDによる副産物による部分が多い気がしてならない。

本エントリでは、TDDの部外者がTDDを見聞きした上で有用であると感じた面を挙げることで、TDDの別の側面にフォーカスを当ててみたいと思う。

プログラマ ミーツ ソフトウェア・テスト:第三次接近遭遇

よくあるTDDの例では、少なくとも(伝統的な開発工程でいう)詳細設計および実装のフェーズと単体テストが一体化している。この結果として、プログラマはどうあがいてもソフトウェア・テストの一部工程(下位Vモデルでのテスト工程の一部)と主体的に関わらざるをえなくなる。

よく考えれば、これは意外と革新的なことだったのではないかと思う。

ものすごく伝統的な職業プログラマの世界では、プログラマとテスターは分かれているし、双方の間に厳然たる身分差があることも多い。プログラマはコードを書き、書いたコードがビルド可能かつ何となく動いているっぽいことを漫然と観察するだけ。テスターはアプリ/システム全体を対象としたテストケースを書き、テストを行い、不具合報告書をあげるだけ。両者は異なる身分で、時にいがみ合っていることもある。

そのような世界においては、一般的な「テストの知識がある/テスト経験がある」プログラマとは、基本情報技術者試験あたりでテストの基礎知識をおざなりに学んだか、初めてプロジェクトに配属された際の役割がテスターだった程度で、コードを書くようになってからはテストとは無縁だったりする。「そんなこたぁテスターにやらしときゃええのよ」。

私自身はそこまで「ものすごく伝統的な職業プログラマの世界」な場面に直面したことはないが、組み込み開発の「プログラマが責任もって単体・結合テストを担当」なプロジェクトと、PCアプリの「単体・結合テストは行わず、アプリ全体のシステムテストで済ます」プロジェクトの双方を経験している。

その経験を元に、誤解を恐れずに書くなら、ソフトウェア・テストに通じているプログラマと通じていないプログラマとでは、明らかにコードの書き方が異なる。特に、単体テスト結合テストでコードの正当性を検証すること」を強く意識しているコードと、その辺りを全く意識していないコード(単体テスト結合テスト形式的にしか実施していないコードを含む)の間には、その書き方に差異が見られる。

ソフトウェア・テストと真剣に向き合っているプログラマは、よくある単純な不具合事例を回避するために防御的にコードを書くし、問題が起きやすい部分(例えば境界値がらみとか、マルチスレッドでの排他など)を実装する際には細心の注意を払う。境界値を気にかけるプログラマは、境界条件における極端なふるまいを考慮し、ときに設計・仕様の不備や矛盾に起因する問題を実装前に発見する。真剣に単体テストを行うプログラマは、段々とテスタビリティを考慮したコーディングをするようになる。「単体テストによる正当性の検証」を視野に入れたプログラマは、できるだけ結合度が低く単機能の関数/メソッドを志すようになる。

ソフトウェア・テストの知見の有無は、書いたコードの品質に良い影響を与える。つまり、ソフトウェア・テストを強く意識することで、テスト実施の有無にかかわらずバグの数が低減する可能性がある。

その意味において、プログラマがテストのことをあまり意識する必要がなかった伝統的な環境から、プログラマが低水準のテスト(単体テスト結合テスト)を通じてテストのことを強く意識する切欠を与える環境への移行は、ソフトウェアの品質を向上させるチャンスではないだろうか?

無論、従来の延長線上に「単体テストをちゃんと実施しましょう」という工程を付け加えるだけでも効果があるかもしれないが、TDDの場合、(「そんな単体テストで大丈夫か?」的問題は発生しうるものの)実装と単体テストが不可分であるので単体テストをサボりにくい、という利点があるといえる。テストをやらずに済むならなにも意識せずにスルーできるが、テストせざるをえないなら否応なく意識することになるのだ。

単体テストによるコスト削減の可能性

TDDの話は、結構な割合で関数/メソッド単位の単体テストとセットになっている。これはつまり、ソフトウェアの品質向上の面では、いわゆる「テスト担当者がシステムを触ってバグを出す」というレベルのテストとは別に、よりソースコードに密着した開発者テストを実施することも有効である、ということだ。

(実施するテストが増えている点に注意。TDDを行ったからといって、従来の「テスト担当者によるテスト」が不要になるわけではない)

このことは、実のところ今更書くようなことではない。遥か昔から「単体テスト結合テスト・統合テスト・システムテスト」と言われてきたではないか。手垢にまみれて古くさく役に立たない(と一般的に言われてそうな)基本情報技術者試験に出てくるほどに鉄板のネタだ。

組み込み開発のように高品質が求められる分野では、関数/メソッド単位での単体テストを実施することが多い。形式的に、ではなくキッチリと実施していれば、それなりにソフトウェアの品質向上に結びつく効果がある。でなければ、単体テストが行われなくなって久しいはずだ。

組み込み業界にいる私からすれば、関数/メソッド単位の単体テスト結合テストは「プログラマの嗜み」というか、「開発者テスト」として責任を持って行うべきものだ。本職のテスター*1には劣るが、ソフトウェア・テストの基礎知識を学んだ上で実務への適用方法を試行錯誤するのは当然のことだ。また、単体テスト結合テストで得られた知見から、コードの書き方を見直すこともある。

しかし一方で、その組み込み業界においても、例えばデバイスと連携するPCアプリやモバイルアプリの開発では、様々な事情で関数/メソッド単位での単体テスト結合テストを行わずにアプリ全体を対象としたテスト(システムテストなど)で済ましてしまうことがある。

そういうアプリは、一見して正しく動作しているようで、実は隠れた爆弾を抱えていたりすることがある。そんなアプリの改修に関わって痛い目に遭った経験がある私としては、最低でも関数/メソッド単位の単体テストぐらいはキッチリと行ってほしいのだが……。

ソフトウェア・テストは、ある意味においてコンクリートのひび割れや空洞の検査に似ている。アプリケーション全体をブラックボックスと見立てた普通のシステムテストは、コンクリートを目視で検査するようなものだ。優秀なテスターなら、打音調査や超音波による検査を行うだろう。

しかし、目視や打音調査などよりも確実な手法がある。コンクリートをスライスして断面を調べることだ。コンクリートの検査では不可能だが、ソフトウェア・テストでは可能だ。コードリーディングによるレビュー、静的解析ツールによるインスペクション、関数/メソッド単位の単体テスト結合テストが該当する。

関数/メソッド単位の単体テストが行われないのは、効果が分かりにくいからだ。あるアプリケーションを実装まで行い、その後の工程を「同じ環境、同じ人員」にて「単体テストあり」と「単体テストなし」に分けて行うことが可能ならば、両者の比較から有意な差の有無が判明するだろう(と私は妄想している)。しかしそれは不可能な話だ。

この不可能な話が可能だと仮定すると、関数/メソッド単位の単体テストによって得られると予想される効果は、ソフトウェアの品質向上と、結合テスト以降(リリース後の保守フェーズを含む)の「不具合発見→設計・実装への手戻り」によるコストの低減だろう。

前者の「品質向上」は、関数/メソッド単位の単体テストを実施すべき論拠としてはやや弱めだろう。顧客からみて「一定以上の品質が保たれている」と感じられるのなら、そこからどれだけ品質を向上しても、直接的な収益に結びつかないからだ。職業プログラマの場合、論理的には「費用対効果」より求められる品質が決まるはず*2なので、仮に「関数/メソッド単位の単体テストをせずとも要求された品質をクリアしている」という状態*3ならば、関数/メソッド単位の単体テストの実施は「過剰品質」となる。

しかし結合テスト以降の工程のコストが低減するのなら話が変わってくる。開発期間の短縮や、場合によっては売上原価の圧縮に結びつくからだ。

また、システムのライフサイクルという観点では、経験的に、関数/メソッド単位の単体テストを行っていないコードは「保守・改修」の工程に禍根を残すことも多い気がする。この直感が正しいならば、関数/メソッド単位の単体テストを実施することによりライフサイクル全体におけるコストが低減する可能性がある、とも考えられる。

こういった、計測の難しい部分のコストへの影響を明らかにすることが可能ならば、単体テストの重要性について開発者以外に説明する際に、有力な論拠となりうるだろう。計測が可能か否か、またどう計測するか、という問題はあるが……。

テスタビリティの高さ≒コードの品質の良さ

先に書いたが、「単体テスト結合テストでコードの正当性を検証すること」を強く意識しているコードと、その辺を全く意識していないコードには、大きな差異がある。

まずは簡単なところから。

良いコードを書こうとするプログラマは、よくある単純な不具合事例を回避するために、防御的にコードを書くようになる。例えば、C言語系統の言語で度々話題となる「{}を付けるか否か」問題だ。防御的にコーディングする人は必ず{}を付けるし、{}を省く人でも明らかに「この程度なら大丈夫だろう」というケース(例えば単文ifでの即時returnによる引数エラーチェックの羅列とか)にとどめることが多い。この派生で、結果を返さずに単に副作用だけを期待する*4関数形式マクロをC言語で実装する際、処理全体を「do {} while (0)」で囲むこともある。

防御的にコードを書こうとするプログラマは、同時に、よく問題が起きやすい部分を実装する際には細心の注意を払うようになる。例えば境界値絡みとか、マルチスレッドでの排他・競合などだ。

特に境界値・境界条件を気にかけるプログラマは、境界条件においてプログラムがどう振る舞うか、またどう振る舞うべきか、時間を割いて検討することが多い。境界条件では「極端な振る舞い」が発生する可能性がある。その辺を考察することで、時に設計・仕様の不備や矛盾に起因する問題を実装前に発見することがある。

基礎的な例だと、入力が「指定された範囲内の整数値」なら範囲の直前・直後及び範囲の最小値・最大値のケースを、文字列なら「0文字の文字列」のケースを、ファイルなら「0 byteのファイル」のケースを、「1行1レコード」のデータファイルなら「改行コードのみの行を含むファイル」や「ファイル末尾に改行コードが存在しないファイル」のケースを考慮するものだ。

まあ、ここまでは、テストによる正当性の検証にこだわっていなくても、良いコードを書こうとしている人なら取り組んでいることが多い。

では単体テストに取り組むようになると、ここからどう変化するのだろうか?

真剣に単体テストを行うプログラマは、段々とテスタビリティを考慮したコーディングを行うようになる。

例えば複雑な条件式は、組み合わせを網羅するテストケースを考えるのが難しいし、また実際にテストする際に「どこまでテストしたか?」という点が曖昧になってしまうものだ。だからテスタビリティを考慮して、複数の「単純な条件式」に分解するようになる。

C言語系の言語にある三項演算子は、便利な反面、複雑で怪しげなコードを書いてしまいやすいという欠点がある。そこで代替案として、短いけど入れ子になって複雑な三項演算子の塊を、あえて複数のif文を用いて「冗長だけど分かりやすい」コードに書き直すことを提案される場合がある。これなども、実のところテスタビリティの観点から同様な結論に達することがままあるものだ。「多少冗長だけど分かりやすい」コードの方がテストケースを考えやすいからだ。

また「単体テストによる正当性の検証」を視野に入れているプログラマは、可能な限り結合度が低く且つ単機能の関数/メソッドを書こうとする。理由は簡単で、その方が単体テストのテストケースを考えやすいからだ。

単純な話、数千行あるような最長不倒関数には大抵色々な機能が詰め込まれているものだ。そんな関数のテストケースを考えるのも、テストケースを充足できるに足るような自動テストスイートを用意するのも、ライフワークとなりかねない危険で複雑な作業だ。

そのような危険を避けようとすると、自然と各関数は単機能になるし、引数の数は少なめになるし、行数は短めになるし、グローバル変数インスタンス変数の参照を抑えた結合度の低い実装になる。結果として、実装された関数は、自然とモジュール強度(凝集度)が高く且つモジュール結合度が低いものが多くなる。

不思議なもので、テスタビリティを考慮してコードを書くことで、自ずとコードの品質が良い方向に変化するのである。

この辺りは、テスタビリティなんて考えず、形式的にしか単体テストを行わない開発者には無理な話だろう。

「テストケース作成」による「仕様」の網羅、明確化

(先に誤解されないように書いておく。TDDにおけるテストは、単体テストとは限らない。TDDで行うのは「そのプロジェクト/プロダクトに見合ったテスト」だ)

仕様策定時に見逃されていた矛盾や曖昧な点に、実装フェーズ終盤に差し掛かってから気づいたことはないだろうか? その矛盾や曖昧な点をどうするか確認した結果、今までの実装の少なくない部分を破棄してやり直す破目になったことはないだろうか?

TDDは「Test」の名を関する手法な訳だが、ソフトウェア・テストには少々興味深い特性がある。テストする前にバグやバグがありそうな場所を発見できることがあるのだ。

ホワイトボックス・テストやグレーボックス・テストを除けば――要はブラックボックス・テストなのだが――テストケースは仕様書を読み解いて作成することになる。ここで、テストの期待結果を明確に記述できないテストケースを発見したなら、おそらく関連する仕様に抜けや漏れがあるか、仕様が曖昧であるはずだ。その周辺にバグが潜んでいる可能性がある。もしくは、不具合とまでは言えないが、お客様に確認していただいたら「そこって、そうじゃないんだよねぇ」とちゃぶ台返しとなるかもしれない。

別の例として状態遷移が挙げられる。仕様書に状態遷移図が書かれていたり、もしくは状態遷移図は書かれていないものの状態遷移っぽい部分が存在することがある。これを状態遷移表に書き直してみると、抜けや漏れ(=怪しい部分)があったり、仕様書には書かれていない「状態」を付け加えないとうまく状態遷移表にならない部分(=漏れ or 考慮し忘れ)があったり、そもそも状態遷移表に書き直せない(=矛盾がある)ことがある。そういった部分には、バグが潜んでいる可能性が高い。

ソフトウェア・テストは、テストを実施すること以上に、実施するテストを考えることが重要だ。テストケースを考えている時点で、未だテストを実施していないのにもかかわらず、バグがありそうな場所が見えてくることがある。

テストケース作成で炙り出せるバグは、仕様策定や設計の段階に遡ることができる問題であることも多い。これはつまり、言い方を変えるならば、「テストケース作成」という形で仕様や設計をレビューしているに等しい。通常のレビューとは別に、再度レビューを行うことで、仕様がより明確となるし、仕様書を隅から隅までチェックすることによる網羅も期待できる。

TDDでは割と早い段階でテストケースを作成する。それは、TDDの入門書のように単体テストかもしれないし、単体テストよりも上位のテスト(システムテストなど)かもしれないが、いずれもテストケース作成を通じて仕様や設計を網羅し、不明点を明確にし、漏れや矛盾をさらけ出す効果を期待できる。

もちろん、何も考えずに形式的にテストケースを作成してしまっては、何の効果も得られないだろう。仕様を網羅するように、且つ怪しそうな部分をピンポイントで狙い撃つ。よいテストケースの作成は難しい。作れるようになるまで時間がかかるものだ。

それでも、実装の前に良質なテストケースを作成できれば、仕様に遡るような問題を減らした状態でコードを書き始めることができる。実装フェーズや、その後のテストフェーズにて、仕様策定や設計の段階への手戻りが発生する可能性は低くなるはずだ。

自動テストスイートによる「気軽にテスト再実施」

TDDの話では、「関数/メソッド単位の単体テスト」と同じくテスト自動化もセットになっていることが多い。

テスト自動化は、初期投資のコストがかかる。例えば、関数/メソッド単位の単体テストの自動化は比較的コストが低い部類に入るのだが、それでも手間がかかる。忙しいと、ついつい単体テストともども省略してしまいたくなるものだ。UIのテストなど、システム全体にまたがるテストを自動化しようものなら、仕組みを作る/導入するコストは更に大きくなる。

しかし、一度仕組みを用意してしまえば、「これほど心強い味方はいない」と言わしめるほど役に立つ。ソフトウェアのバグ修正、既存機能の改良、新機能の追加の度に、修正を加えた部分のテストケースのみ手を加えた上で全体をテストし直すことで、追加・変更点の再テストと同時に既存部分のリグレッションテストを済ますことができる。

ソフトウェアに変更を加えた後の再テストは意外と大変だ。直接変更を加えた部分だけでなく、変更が波及するだろう部分もテストし、デグレードがないことを確認しなくてはならない。

デグレードを見つけるには、変更の波及が想定される部分を推定してそこだけテストするか、ソフトウェア全体を再テストするしかない。変更の波及箇所を推定する方法、実施範囲を抑えることができる反面、見落としによるテスト漏れが発生する可能性がある。安全なのは「全体を再テスト」だが、テスト方法が全て手動ならばコストが高くつくし、プレッシャーの下で手作業でテストしているならば、どこかで手順間違いなどのミスが起こりうる。

だがテストが自動化されていれば、ソフトウェア全体を再テストする際のコストが低くなる。コストが低ければ、気軽にテストすることができる。見落とされがちだが、「気軽にテストできる」ということは重要だ。

一旦リリースしたソフトウェアが、保守・改良されつつ長く長く使われ続けるほどに、自動テストは開発期間の短縮と一定品質の保持という効果をもたらす。パッケージソフトや組み込み製品開発など、競合が多数存在する分野においては、初版リリース後のバグ修正や新機能追加におけるフットワークの軽さは武器の一つとなりうる。

その意味においては、自動化の対象を単体テストに絞る必要はない。いや、もちろん単体テストの自動化も効果があるのだが、より広範囲の、システム全体にまたがるテストの自動化も重要となる。それこそ、Webアプリ界隈ではブラウザの互換性の問題もあり、GUIに関わるテストを自動化しようという動きが見られる。こまめに修正・改良とデプロイを繰り返したいのなら、テストの自動化は必須なのだ。

この特徴ゆえに、テスト自動化は反復型開発と親和性が高く、反復型であるアジャイル開発(特に変化を前提とするXP)にて取り上げられるのだろう。

テスト自動化については、保守コストが発生する点も考慮する必要がある。継続的に開発を続けていくプロジェクトでは、刻一刻と変化していく環境に合わせて自動テスト・システムも改良されていくし、プロジェクトが継続している以上は自動テスト・システムも存在し続けても大丈夫だろう。しかし「Ver.1完成→チーム解散→数年後にVer.2開発開始」というスタイルの開発では、例えばVer.1の自動テスト・システムをVer.2開発で使用しようと試みた際、改めて自動テスト・システムが動作する環境を構築し直す作業が発生するし、OSや開発ツールや開発マシンの変化に一気に追随する大きな手間もかかるだろう。

仮に今後、テスト自動化を広めていきたいのならば、この種の保守コストとどう向き合うべきか考える必要があるだろう。場合によっては、「プロジェクト」のあり方を議論する必要があるかもしれない。

まとめ

TDD部外者から見て、TDDには次のような有用な要素が含まれている。

  1. 開発者にソフトウェア・テストを強く意識させる(他人事だと思わせない)。
  2. よりソースコードに近いレイヤーのテスト(単体テストなど)の有用性を明示している。
  3. テストを意識することで、テスタビリティの高い、ソースコードの読者からみて見通しのよいコードを書くようになる契機となりうる。
  4. テストケース作成にあたり、開発対象の仕様を明確化し、漏れのないよう網羅しようという意識が(プログラマにも)働く。
  5. 自動テストにより、リグレッションテストのコストを引き下げる。リリース後のバージョンアップ等における開発期間を短くする効果がある。

上記の各要素、特に2~4は、よく見てみれば昔から指摘されてきた。決して新しい内容ではない。

単体テストは重要だ。見通しのよいコードを書くことも重要だ。仕様を明瞭にして漏れのないようにすることだって重要だ。

どれも重要だが、しかし現実のソフトウェア開発において軽視させることが多かった。明確な仕様もなく(それどころか断片的な仕様さえ1週間も経たずにころころ中身が変わる)、ろくに単体テストもされていない、見通しの悪い○○なコードで構築されたソフトウェアの何と多いことか。

TDDが巧妙なのは、ソフトウェアの実装フェーズにテスト(最低でも単体テスト)を組み込み、一体化させることで、プログラマを否応なくソフトウェア・テストの世界に引きずり込もうとしている点だ。しかも、プログラマにソフトウェア・テストを意識させることで、暗黙のうちに前述の各要素を強制させている。

もちろん、テストの追加を「単なる重荷」ととらえて、形式的にしかTDDしない開発者もいるだろう。そのような開発者の手にかかれば、TDDだろうと何だろうと効果はゼロだ。しかしTDDを契機にソフトウェア・テストに積極的に取り組み、単体テストを行い、テスタビリティの観点からコードの書き方を改善し、仕様の網羅・明確化という観点で問題を洗い出すようになる開発者だっているだろう。

個人的には、上記の点がTDDのメリットであり、これらのメリットが得られるのならテスト・ファーストに徹する必要は無いと考えている。しかし、私を含めて悪い意味での「怠惰なプログラマ」がサボってしまいがちなこれらの要素を、TDDは暗黙のうちに強制しようと働く。その意味でTDDは巧妙で、実に有用な興味深い手法だと言えるだろう。

追記

筆者は研究開発系のプロジェクトに関わることが多く、「そもそも、このアルゴリズムで大丈夫なのか?」という不明点が多々ある段階で設計・コーディングを行うことが大半である。

だから、試作品を何度も揉んで、ある程度「この方法で実現できそう」という感触が得られてから、関数単位での単体テストや、ライブラリ単位での統合テストに着手している。それより前に単体テストに着手すると、試作品を揉んだ結果として「データ構造を含む大規模な見直し」が発生したので単体テストも大幅に書き直す――なんてことが発生して、時間的コストがかかり過ぎるからだ。

そういう事情があるので、厳密なTDDは行っていない。ただし、単数単位の単体テストは実施しているし、単体テストの自動化もしている。

おそらく私の環境においては「試作品を何度も揉んで、ある程度『この方法で実現できそう』という感触が得られ」るという粒度が、TDDにおける「自分のシステムにおいて期待する振る舞いを記述するテストを書き、それをパスさせる」という手法に適した大きさなのだろう。

単体テストは重要な要素ではあるものの、単体テストが「おま環」でのTDDに適した粒度であるとは限らない――という点を考慮した上で、TDDについて評価すべきだろうし、評価結果を提示する時は「おま環」について併記すべきだろう。でないと議論が噛み合わないはずだ。

*1:本職のテスト担当の人は、優秀な人は本当に優秀だ。このバグ、どうやって見つけたのだろう……。

*2:実際には、まあ、色々とありまして……。

*3:現実的に、このような状況がありうるか疑問ではある。

*4:Pascalのprocedureのような感じ。