レベル: 初級 Andrew Glover (aglover@stelligent.com), President, Stelligent Incorporated
2007年 9月 18日 テスト駆動開発 (TDD) は実際には素晴らしいアイデアですが、一部には、テストという言葉に結び付けられた考え方の飛躍にどうしてもついていけないという開発者もいます。そこでこの記事では、より自然な形で TDD のこの考え方をプログラミング・プラクティスに取り入れる方法を紹介します。JBehave を使ったビヘイビア駆動開発 (BDD) を試して、結果ではなくプログラムの振る舞いに重点を置くと、どんな変化が生まれるのか自分自身で確かめてください。
開発者テストが有効なことは確かです。テストを初期段階 (例えばコードを作成するとき) に済ませれば、特にコードの品質という点から言うと一層効果があります。最初にテストを作成することが、成功を確実にします。コードの振る舞いを検討してあらかじめデバッグできるという強みが加われば、間違いなく開発が順調に進むからからです。
そうとわかってはいても、コーディングの前にテストを作成することを標準的プラクティスに至らせるには程遠いのが現状です。Extreme Programming から TDD が進化してユニット・テスト・フレームワークが脚光を浴びるようになったのと同じように、TDD という土台からの進化の飛躍が待ち望まれています。そこで今月は、TDD からの進化の飛躍により、TDD よりもいくらか直観的になったビヘイビア駆動開発 (BDD) を紹介します。
コードの品質向上
ビヘイビア駆動開発
テスト・ファースト・プログラミングは一部の人々には効果を発揮しますが、すべての人にそれが当てはまるわけではありません。TDD を熱心に支持するアプリケーション開発者がいる一方で、多くの開発者が TDD にあからさまに抵抗しています。TestNG、Selenium、FEST など、テスト・フレームワークは豊富にありますが、それでもコードをテストしないという理由にはさまざまなものがあります。
TDD を実践しない理由として一般的なのは、「テストを行う時間の余裕がない」、そして「コードが複雑すぎるため、テストするのが難しい」というものです。また、テスト・ファーストの概念自体も、テスト・ファースト・プログラミングの障害となっています。開発者の多くは、テストを実際に触れて行う作業として捉えています。つまり、抽象的というよりは具体的な活動です。経験からすると、まだ存在していないものをテストすることはできません。このような概念の枠組みから、テストを先行させるという考えは矛盾していると考える開発者もいます。
しかし、テストの作成やテストの方法という観点から考える代わりに、振る舞い (ビヘイビア) について考えてみたとしたらどうでしょう。ここで言う振る舞いとはアプリケーションがどのように振る舞うべきかということ、要するにアプリケーションの仕様のことです。
すると、このような考えは誰もが持っていることに気付くはずです。例えば、以下の会話に注目してください。
フランク:
スタックとは何ですか?
リンダ:
スタックというのは、オブジェクトをファーストイン・ラストアウト (またはラストイン・ファーストアウト) で集めるデータ構造のことです。通常、スタックの API には push() や pop() のようなメソッドがあります。場合によっては peek() メソッドが含まれることもあります。
フランク:
push() にはどんな機能があるのですか?
リンダ:
push() は入力オブジェクト、例えば foo を取って、これを配列などの内部コンテナーに配置します。push() は通常、何も返しません。
フランク:
push() を 2 つのオブジェクトに対して実行した場合はどうですか? 例えば foo と bar とか。
リンダ:
2 番目の bar オブジェクトが 2 つ以上のオブジェクトを含む概念スタックの先頭に来るはずなので、pop() を呼び出すと、最初のオブジェクト、つまり foo の代わりに bar が取り出されることになります。pop() を再度呼び出すと、この 2 つのオブジェクトを追加する前にスタックに何も含まれていなかった場合、今度は foo が返されてスタックが空になるはずです。
フランク:
つまり、pop はスタックに最後に配置された項目を削除するということですか?
リンダ:
その通りです。削除対象の項目がある場合、pop() は先頭の項目を削除することになっています。peek() もこれと同じ規則に従いますが、オブジェクトは削除されません。peek() の場合はスタックの先頭項目を残すことになっているからです。
フランク:
何もプッシュしないで pop() を呼び出した場合はどうですか?
リンダ:
すると pop() が例外をスローして、まだ何もプッシュされていないことを通知するはずです。
フランク:
push()
null を実行した場合は?
リンダ:
null は push() の値として有効ではないので、スタックは例外をスローするはずです。
この会話に特に変わったことはありません (フランクがコンピューター・サイエンスを専攻しなかったという点を除いては)。「テスト (test)」という言葉はどこにも出てきませんでしたが、代わりに「~するはず、~することになっている (should)」といった言葉で自然に言い換えられています。
期待される振る舞いに従って開発する
 |
どのフレームワークを使うべきか
注釈を使用することで、JUnit および TestNG による BDD の実践が可能になりますが、私がそれよりも興味深いと思うのは、JBehave のような BDD フレームワークを使用することです。JBehave には、より文芸的なプログラミング・スタイルを容易に実現できるエクスペクテーション (expectation) フレームワークなど、ビヘイビア・クラスを定義するための機能が備わっています。
|
|
BDD は新しいものでも革命的なものでもありません。これは単に TDD から進化して派生したもので、「test」という言葉が「should」という言葉に置き換えられているだけです。セマンティクスは別として、多くの人々にとってはこうあるべき (should) という概念のほうが、テストという概念よりも遥かに自然に開発を進める力となります。振る舞い (あるべき姿) という観点で考えることが、仕様クラスを先行して作成するという道筋をつけ、結果的には極めて効率的な実装要因をもたらすはずです。
ここからはフランクとリンダの会話に基づいて、TDD に意図されていたように BDD が開発を推進する過程を辿っていきましょう。
JBehave
JBehave は Java™ プラットフォーム用 BDD フレームワークで、xUnit パラダイムに発想を得ています。ご想像のとおり、JBehave が重点を置いているのは test ではなく should です。JUnit と同じく、JBehave のクラスはいつも使っている IDE とお好みのビルド・プラットフォーム (Ant など) を使って実行することができます。
JBehave では JUnit での場合とほとんど同じようにビヘイビア・クラスを作成できますが、JBehave では特定の基本クラスから拡張する必要はありません。また、すべてのビヘイビア・メソッドは test ではなく should で始まります (リスト 1 を参照)。
リスト 1. スタック用の単純なビヘイビア・クラス
public class StackBehavior {
public void shouldThrowExceptionUponNullPush() throws Exception{}
public void shouldThrowExceptionUponPopWithoutPush() throws Exception{}
public void shouldPopPushedValue() throws Exception{}
public void shouldPopSecondPushedValueFirst() throws Exception{}
public void shouldLeaveValueOnStackAfterPeep() throws Exception{}
}
|
リスト 1 に定義したメソッドはいずれも先頭が should から始まり、人間が読み取れるセンテンスとなっています。この StackBehavior クラスには、フランクとリンダの会話に出てきたスタック機能の多くが記述されています。
例えばリンダの説明によると、スタックはユーザーが null を配置しようとすると例外をスローすることになっています。StackBehavior クラスの最初のビヘイビア・メソッドを見てください。このメソッドの名前は shouldThrowExceptionUponNullPush() です。このネーミング・パターンは他のメソッドにも当てはまります。この後すぐに説明しますが、このように記述的なネーミング・パターン (決して JBehave や BDD に特有のパターンではありません) によって、人間が読み取れるように失敗の振る舞いを規定できるようになっています。
ここで、shouldThrowExceptionUponNullPush() の振る舞いを検証する方法を考えてみてください。 まずは Stack クラスに push() メソッドが必要になるはずですが、これを定義するのは簡単です。
リスト 2. 振る舞いを調査しやすくするための単純なスタック定義
public class Stack<E> {
public void push(E value) {}
}
|
ご覧のように最小限のスタックをコーディングしてあるので、まずは必要な振る舞いを肉付けするところから取り掛かれるようにしています。リンダが言ったように、shouldThrowExceptionUponNullPush() の振る舞いは単純で、誰かが null 値を指定して push() を呼び出すとスタックが例外をスローするというものです。私はこの振る舞いをリスト 3 のように定義しました。
リスト 3. null がプッシュされると例外をスローするスタック
public void shouldThrowExceptionUponNullPush() throws Exception{
final Stack<String> stStack = new Stack<String>();
Ensure.throwsException(RuntimeException.class, new Block(){
public void run() throws Exception {
stStack.push(null);
}
});
} |
大いなる期待とオーバーライド
リスト 3 には JBehave 特有の内容が含まれているので、それについて説明しておきましょう。このリストではまず Stack クラスのインスタンスを作成し、その型を String に限定しています (Java 5 Generics を使用)。次に JBehave のエクスペクテーション・フレームワークを使用して、期待する振る舞いの基本的なモデル化を行いました。Ensure クラスは JUnit や TestNG の Assert タイプと似ていますが、このクラスは API をさらに読みやすくする一連のメソッドを追加します (文芸的プログラミングと呼ばれる手法です)。リスト 3 では、null を指定した push() が呼び出されると RuntimeException がスローされるようにしています。
JBehave では他に Block タイプも導入しています。このタイプは、run() メソッドを期待する振る舞いでオーバーライドして実装します。すると JBehave はその内部で、必要な例外タイプがスローされない場合 (したがって、キャッチされない場合)、失敗状態が生成されるようにします。Google Web Toolkit による Ajax のユニット・テストについて説明した前回の記事で、同じようなパターンでコンビニエンス・クラスをオーバーライドしたのを覚えているかもしれませんが、この場合のオーバーライドは GWT の Timer クラスを使用して行われています。
現時点ではリスト 3 の振る舞いを実行しても失敗するはずです。現状のようにコーディングされた push() メソッドは何も実行しません。したがって、リスト 4 の出力からわかるように、例外が生成されることはないということです。
リスト 4. 必要な振る舞いの欠如
1) StackBehavior should throw exception upon null push:
VerificationException: Expected:
object not null
but got:
null:
|
リスト 4 のセンテンス「StackBehavior should throw exception upon null push」は、クラスの名前を使って振る舞いの名前 (shouldThrowExceptionUponNullPush()) を真似ています。ここで基本的に JBehave がレポートしているのは、期待される振る舞いを実行したときに何も受け取らなかったということです。そこで当然、この振る舞いを合格させることが次のステップとなります。その方法として、リスト 5 に示すように null をチェックします。
リスト 5. スタック・クラス内での特定の振る舞いの追加
public void push(E value) {
if(value == null){
throw new RuntimeException("Can't push null");
}
}
|
これで振る舞いをもう一度実行すると、すべてが順調に進みます (リスト 6 を参照)。
リスト 6. 成功!
Time: 0.021s
Total: 1. Success!
|
振る舞いを中心とした開発
リスト 6 の出力は JUnit の出力と似ていると思いませんか? これは偶然ではありません。前述したように、JBehave は xUnit パラダイムに従ってモデル化されており、setUp() と tearDown() を介してフィクスチャーをもサポートします。ビヘイビア・クラス全体で Stack インスタンスを使用するとしたら、リスト 7 に示すように、そのロジックをフィクスチャーにも適用することもできます。注意する点として、JBehave は JUnit と同じフィクスチャー契約に従います。要するに、あらゆるビヘイビア・メソッドで setUp() および tearDown() を実行するということです。
リスト 7. JBehave のフィクスチャー
public class StackBehavior {
private Stack<String> stStack;
public void setUp() {
this.stStack = new Stack<String>();
}
//...
}
|
次に取り掛かるのはビヘイビア・メソッド、shouldThrowExceptionUponPopWithoutPush() です。これはつまり、このメソッドにもリスト 3 の shouldThrowExceptionUponNullPush() と同様の振る舞いを確実にする必要があるということです。リスト 8 を見るとわかるように、ここでは何も特別なことはしていません。
リスト 8. pop の振る舞いの確定
public void shouldThrowExceptionUponPopWithoutPush() throws Exception{
Ensure.throwsException(RuntimeException.class, new Block() {
public void run() throws Exception {
stStack.pop();
}
});
}
|
おそらく見当が付いているかもしれませんが、実際にはリスト 8 のコンパイルはこの時点で行われません。pop() がまだ作成されていないからです。pop() を作成する前に、いくつかの点を検討する必要があります。
振る舞いを確定する
理論的に言えば、この時点で呼び出し順とは関係なく単純に例外をスローするように pop() を実装することも可能です。しかしこの振る舞いの過程を辿ってみると、目的の仕様をサポートする実装を考えたほうがよいという気になります。この例では、push() がまだ呼び出されていない場合 (つまり、論理的にはスタックが空の場合) に pop() に例外をスローさせるということですが、これはつまり、スタックには状態があることを意味します。上記の会話でリンダが考えたように、スタックには通常、実際に項目を保持する「内部コンテナー」があります。したがって、push() メソッドに渡された値を保持する Stack クラス用の ArrayList を作成することができます。
リスト 9. スタックが必要とする内部でオブジェクトを保持する手段
public class Stack<E> {
private ArrayList<E> list;
public Stack() {
this.list = new ArrayList<E>();
}
//...
}
|
このようにした上で pop() メソッドの振る舞いをコーディングすれば、スタックが論理的に空の場合に間違いなく例外がスローされるようにすることができます。
リスト 10. 容易になった pop の実装
public E pop() {
if(this.list.size() > 0){
return null;
}else{
throw new RuntimeException("nothing to pop");
}
}
|
リスト 8 の振る舞いを実行すると、期待通りに事が運びます。スタックは値を保持していないため (したがって、スタックのサイズはゼロ)、例外がスローされます。
次のビヘイビア・メソッドは shouldPopPushedValue() ですが、このメソッドを記述するのは簡単です。値 ("test") を指定して push() を実行し、pop() を呼び出したときに同じ値が返されることを確認するだけに過ぎません。
リスト 11. プッシュされた値が取り出されるかどうかの確認
public void shouldPopPushedValue() throws Exception{
stStack.push("test");
Ensure.that(stStack.pop(), m.is("test"));
}
|
Matcher のダイヤル M を廻せ
 |
UsingMatchers 型について
お気付きかもしれませんが、 リスト 12 のコードは洗練されているとは言えません。リスト 11 の m が多少なりとも読みやすさに影響しているからです (「ensure that pop's value m (what the?) is test」)。UsingMatchers 型を使用しなくても済むようにするには、JBehave が提供する特殊な基本クラス (UsingMiniMock) を拡張するという方法があります。このようにすると、リスト 11 の最後の行は Ensure.that(stStack.pop(), is("test")) となり、少しは読みやすくなります。
|
|
リスト 11 では pop() が値 "test" を返すことを確認していますが、JBehave の Ensure クラスを使う過程では、もっと詳細に期待値を指定する方法が必要になることも珍しくありません。JBehave がこのニーズを満たすために提供しているのは、詳細な期待値を実装するための Matcher タイプです。この例では、JBehave の UsingMatchers タイプ (リスト 11 の m 変数) を再利用することにしたので、is()、and()、or() のようなメソッドやその他多くの巧みなメカニズムを使って一層文芸的なスタイルで期待値を構成できます。
リスト 11 の m 変数は、StackBehavior クラスの静的メンバーです (リスト 12 を参照)。
リスト 12. ビヘイビア・クラスの UsingMatchers
private static final UsingMatchers m = new UsingMatchers(){};
|
この新しいビヘイビア・メソッドをリスト 11 にコーディングして実行したとしても、この段階では失敗するはずです (リスト 13 を参照)。
リスト 13. 新しくコーディングした振る舞いの失敗
Failures: 1.
1) StackBehavior should pop pushed value:
java.lang.RuntimeException: nothing to pop
|
何が原因かと言うと、push() メソッドがまだ完成していないためです。リスト 5 をもう一度見てみると、振る舞いを機能させるために最低限必要な実装しかコーディングされていません。そこでこのジョブを完了するために、プッシュされた値を内部コンテナーに実際に追加することにします (値が null でない場合)。その方法は、リスト 14 のとおりです。
リスト 14. push メソッドの仕上げ
public void push(E value) {
if(value == null){
throw new RuntimeException("Can't push null");
}else{
this.list.add(value);
}
}
|
でも待ってください。この振る舞いを実行しても、まだ失敗します。
リスト 15. 例外ではなく null 値をレポートする JBehave
1) StackBehavior should pop pushed value:
VerificationException: Expected:
same instance as <test>
but got:
null:
|
リスト 15 の失敗は少なくともリスト 13 の失敗とは異なっています。今度は "test" 値が見つからないことから、例外がスローされる代わりに null が取り出されています。何が問題かは、リスト 10 をよく見てみるとわかります。ここでは内部コンテナーに何も含まれていない場合、null を返すように pop() メソッドをコーディングしているからです。そうとわかれば修正するのは簡単です。
リスト 16. コーディングが完了した pop メソッド
public E pop() {
if(this.list.size() > 0){
return this.list.remove(this.list.size());
}else{
throw new RuntimeException("nothing to pop");
}
}
|
この振る舞いを実行すると、今度は新しい失敗が示されます。
リスト 17. 新たな別の失敗
1) StackBehavior should pop pushed value:
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
|
リスト 17 に示されている情報をじっくり読むと、問題の原因が明らかになります。ArrayList を扱う際には、0 を考慮しなければならないということです。
リスト 18. 0 の考慮による問題の解決
public E pop() {
if(this.list.size() > 0){
return this.list.remove(this.list.size()-1);
}else{
throw new RuntimeException("Nothing to pop");
}
}
|
スタックのロジック
ここまでのところで、多数のビヘイビア・メソッドを渡せるように push() と pop() を実装することができましたが、スタックの内容に対処する必要がまだ残っています。スタックの内容とは、複数の push() と pop() に関連付けられるロジックのことです。さらに、時折スローする peek() にも対処しなければなりません。
まずは、shouldPopSecondPushedValueFirst() の振る舞いによって、スタックの基本アルゴリズム (ファーストイン・ラストアウト) が安定していることを確認します。
リスト 19. 標準的スタック・ロジックの確認
public void shouldPopSecondPushedValueFirst() throws Exception{
stStack.push("test 1");
stStack.push("test 2");
Ensure.that(stStack.pop(), m.is("test 2"));
}
|
リスト 19 のコードは計画通りに動作するので、今度は別のビヘイビア・メソッド (リスト 20 を参照) を実装して、pop() を 2 回使用しても正しい振る舞いになることを確認します。
リスト 20. さらに掘り下げたスタックの振る舞い
public void shouldPopValuesInReverseOrder() throws Exception{
stStack.push("test 1");
stStack.push("test 2");
Ensure.that(stStack.pop(), m.is("test 2"));
Ensure.that(stStack.pop(), m.is("test 1"));
}
|
次に、peek() が期待通りに動作することを確認することにします。リンダが言ったように、peek() は pop() と同じ規則に従いますが「スタックの先頭項目を残す」ことになります。これに従って shouldLeaveValueOnStackAfterPeep() メソッドの振る舞いを実装したものが、リスト 21 です。
リスト 21. peek がスタックの先頭項目を残すかどうかの確認
public void shouldLeaveValueOnStackAfterPeep() throws Exception{
stStack.push("test 1");
stStack.push("test 2");
Ensure.that(stStack.peek(), m.is("test 2"));
Ensure.that(stStack.pop(), m.is("test 2"));
}
|
peek() はまだ定義されていないため、リスト 21 のコードはコンパイルされません。そこで、リスト 22 に必要最小限の peek() 実装を定義しました。
リスト 22. 当然必要となる peek
public E peek() {
return null;
}
|
これで StackBehavior クラスはコンパイルされるようになりましたが、実行するまでには至っていません。
リスト 23. 当然の結果として返される null
1) StackBehavior should leave value on stack after peep:
VerificationException: Expected:
same instance as <test 2>
but got:
null:
|
論理的には、peek() は内部コレクションから項目を削除しません。このメソッドは基本的に、項目へのポインターを渡すだけです。そのため、ArrayList では remove() ではなく get() メソッドを使用します (リスト 24 を参照)。
リスト 24. 削除しないこと
public E peek() {
return this.list.get(this.list.size()-1);
}
|
スタックが空の場合
リスト 21 の振る舞いを再実行すると合格レベルの結果が得られます。この演習では問題が明らかになりましたが、スタックが空の場合の peek() はどのように振る舞うと思いますか? pop() はスタックが空であれば例外をスローするので、peek() も同じく例外をスローするべきでしょうか。
リンダはこのことについては何も触れていないため、私が自分で新しい振る舞いを追加する必要があるようです。リスト 25 に、「push() を呼び出さずに peek() が呼び出された場合」のシナリオをコーディングしました。
リスト 25. push なしで peek が呼び出された場合
public void shouldReturnNullOnPeekWithoutPush() throws Exception{
Ensure.that(stStack.peek(), m.is(null));
}
|
この場合も当然のことながら、リスト 26 のとおり失敗に終わります。
リスト 26. 対象のない peek
1) StackBehavior should return null on peek without push:
java.lang.ArrayIndexOutOfBoundsException: -1
|
この欠陥を修正するロジックは、pop() のロジックとかなり似ています (リスト 27 を参照)。
リスト 27. peek() に必要な修正
public E peek() {
if(this.list.size() > 0){
return this.list.get(this.list.size()-1);
}else{
return null;
}
}
|
これまでに説明したすべての変更と修正を Stack クラスに加えると、リスト 28 に記載するコードになります。
リスト 28. 正常に動作するスタックの完成
import java.util.ArrayList;
public class Stack<E> {
private ArrayList<E> list;
public Stack() {
this.list = new ArrayList<E>();
}
public void push(E value) {
if(value == null){
throw new RuntimeException("Can't push null");
}else{
this.list.add(value);
}
}
public E pop() {
if(this.list.size() > 0){
return this.list.remove(this.list.size()-1);
}else{
throw new RuntimeException("Nothing to pop");
}
}
public E peek() {
if(this.list.size() > 0){
return this.list.get(this.list.size()-1);
}else{
return null;
}
}
}
|
これでやっと、StackBehavior クラスはリンダの仕様 (そして私独自の多少の仕様) に従って Stack クラスを動作させる 7 つの振る舞いを実行するようになりました。Stack クラスがリファクタリングを使用することも考えられますが (pop() メソッドが size() チェックの代わりに peek() をテストとして呼び出すなど)、ビヘイビア駆動プロセスのおかげで、私はほとんど誰にも知られずに変更を加えるためのインフラストラクチャーを手にできました。何か壊してしまったとしても、それはすぐに通知されるはずです。
まとめ
お気付きかもしれませんが、今月のビヘイビア駆動開発 (BDD) を探る記事で紹介したリンダは、基本的にはカスタマーです。このシナリオでは、フランクを開発者と見なすことができます。このシナリオ (この場合はデータ構造) を別の領域の話 (例えばコール・センターのアプリケーションなど) に置き換えたとしても、作業の流れは同様です。カスタマー、あるいはドメイン専門家のリンダがシステム、機能、あるいはアプリケーションが実行すべき内容を指定し、フランクのような者が BDD を使って、リンダの指定内容を正しく理解し、その要件を確実に実装します。
多くの開発者にとって、テスト駆動開発から BDD に移行するのは賢い行動です。BDD ではテストについて考える必要はありません。単にアプリケーションの要件に注意を払い、これらの要件を満たすためにアプリケーションがすべきことをアプリケーションの振る舞いによって確実に行われるようにするだけです。この記事の例では、BDD と JBehave を使用したことによって、リンダの仕様に従って正常に動作するスタックを簡単に実装することができました。私は、振る舞いという点をまず先に考えながら、ただリンダの話す内容を聞き、それに従ってスタックをビルドしただけです。その過程のなかで、リンダがスタックについて忘れていたいくつかの点も発見することができました。
参考文献 学ぶために
製品や技術を入手するために
議論するために
著者について
記事の評価
|