2015年2月5日
型付けを活用してテストを減らす:静的型を使ったTDD Part 1
本記事は、原著者の許諾のもとに翻訳・掲載しております。
私はテスト駆動開発(TDD)について、Kent Beckの著書『 Test-Driven Development By Example 』(邦訳『テスト駆動開発入門』)で学びました。これは大変優れた入門書で、TDDにますます関心を持つようになった私は、さらにSteve FreemanとNat Pryceの著書『 Growing Object-Oriented Software, Guided by Tests 』(邦訳『実践テスト駆動開発:テストに導かれてオブジェクト指向ソフトウェアを育てる』)を読みました。この本も私のお気に入りです。
ただし、両書には弱い部分もあります。現代の静的型システムがテストを補ったり、場合によっては置き換えたりできるかもしれないことには、全く触れていないのです。このような本を読んだだけでは、”typing”(型付け)と聞いてもキーボードの”タイピング”のほうを考えてしまいがちです。
Kentは、2012年に「 Functional TDD: A Clash of Cultures 」(関数型TDD:文化の衝突)と題した短い記事をFacebookに投稿しています。私は、この記事をきっかけにして、型に影響される現代のTDDと関数型プログラミングに関する議論が始まることを期待していましたが、話はどうも立ち消えになってしまったようです。そこで、この投稿で議論を再開したいと思います。TDD、型、関数型プログラミングについて語る資格のある人は他に大勢いますが、私はこれ以上待ちきれません。
テスト駆動設計を改善するための2つの目標
私にとって、TDDは大いに役立っています。負担の少なく意図的な形でプロトタイプの作成を繰り返すこの手法は、 GTDのタイムマネジメント とよく調和します。ですがTDDは、良い設計を生むための特効薬というわけではありません。実際、テストをたくさん実行したのに設計はまずいという結果に終わってしまうこともよくあります。テストが設計改善の大きな妨げになることさえあるのです。
私は、2つの大きな目標を追加すると、まずい設計に陥る可能性を少なくできることを発見しました。
- 正確さを最大限にして、コードの量を最小限に抑える。
- 悪いコードを書くことが不可能になるようにする。
1番目の目標には、異論はないでしょう。これは他にも様々な表現が知られています。”YAGNI”(You Ain’t Gonna Need It<必要にならないかもしれない>)、”KISS”(Keep It Stupid Simple<シンプルにしておけ!この間抜け>)、”オッカムの剃刀”、そしてKnuthの言葉として有名な「時期尚早な最適化は諸悪の根源」などです。私は、自分の表現が気に入っています。なぜなら、これが単なる最適化ではなくマクシミンの問題だということを思い出させてくれるからです。
2番目の目標はあまり一般的ではなく、意外に思われるかもしれません。私が最初にこれを聞いたのはPaul SnivelyとAmanda Laucherからでしたが、彼らはJane StreetのYaron Minskyがブログで書いた「不正な状態を表現不可能にする」から学んだそうです。この考え方が重要だと思うのは、ゆるやかで表現に富んだオブジェクト指向のシステムを作りがちな私に、警告を与えてくれるからです。もし、ある種のエラーがシステム的に表現不可能になっていればエラーの出ようがないので、そのエラーに対処する必要もなくなります。分かりやすい例を挙げれば、nullポインタを持たない言語ではnullポインタエラーが起きることはあり得ません。
これらの目標は、私のTDDテストについても当てはまります。実際には、私はテストコードに対してもっと厳しい姿勢をとっています。というのも、テストコードは、最初の試行段階ではおそらく十分な記述ができていません。なぜなら、最も事情が分かっていない状態でものを作り出し解空間を探っているからです。前述の2つの目標をテスト向けの形に書き直すと、以下のようになります。
- カバレッジを最大限にして、テストコードの量を最小限に抑える。
- エラーのためのテストをやめて、エラーが不可能になるように設計する。
TDDを好む人がテストコードを最小限に抑えようとするというのは、奇妙な感じがします。TDDに従いながら1つのテストだけで済ませることは可能なのでしょうか? かなり難しいように思えますが、不可能でも不都合でもありません。そのようなテストオラクルは、 git bisect を用いれば、テストを失敗させたコードを見つけるのに多分使えるでしょう。ですので、テストの数が少ないからといって悪いコードを見つけにくくなるわけではありません。私は、TDDでしょっちゅうテストを追加していて、テストの数は気にしていません。それよりも、カバレッジや品質、設計に不足がないか注意しています。
テスト駆動開発での型の使用
型は、プログラマが開発した様々なツールの中でも、私の2つの目標を達成する上で最も役に立つものの1つです。型の素晴らしい点は、一貫性を保証してくれるとともに、記述を簡潔にしてくれるところです。テストファースト開発と同様に、型ファースト開発では、要件、インターフェース、不変条件についてよく考える必要があります。テストでの制約はたいてい具体的なものですが、型では一般的な制約が生じますので、従来の設計事項と一貫性のあるコードを今後書いていくのに役立ちます。
型を使うことで、テストは単なる”グリーンバー”のテストフェーズでなく、コードにとって不可欠な要素となります。コンパイラの型検査はテスト成功と同じ信頼性がありますが、テストとは異なり、その信頼性はコードの全ユーザに広がるのです。
型の構文が扱いにくく冗長な言語もありますが、そうした言語でさえ有用な型を持つことができます。この投稿では型システムに多くのことを求めるつもりはありませんが、以下に挙げる言語の機能は本当に重要です。
- 静的型検査 – プログラムの一貫性を証明
- 不変性 — 副作用のないことを保証
- 総称性 – 型と継承を1つにまとめることなくコードを再利用
それほど強力ではない型システムでも役に立つということが分かりやすいように、Javaの例を挙げて説明していきたいと思います。これにはいくつか利点があります。Javaは前述の両書で使われていた言語であること、Javaで書かれたひどいテストは簡単に見つかること、そして一番重要なのは、Javaが最低限の実用的な型システムを備えていることです。Javaでできることは、おそらく他の言語ではもっと簡単にできるでしょう。
私は、型で証明するのが難しいことも結構あると分かったため、今でもたくさんのテストを書きます。私にとっては”テストか型か”の問題ではなく、どうすれば自分のコードが機能することを十分に証明できるかという問題なのです。私はたいていテストから始めて型を導いたあと、ループバックしてさらにテストを追加します。いつも型ファーストで始めるという人や、受け入れスタイルのテストの代わりにもっと確かな形の証明方法を見つけたという人がいれば、話を聞いてみたいです。
まずREPLが必要
私が仕事を始める時はいつも、コードを素早く試せる環境をセットアップします。それに最適な環境であるREPL(read-evaluate-print-loop<入力を読み取り、評価を行い、その結果を出力するループ>)では、入力したコードが即座に実行されます。Javaには通常REPLがありませんが、スタブテストを作成すればIntelliJでcommand-Rキーを押すことでテストを実行できます。以下が骨格となるREPLです。
package com.atomicobject;
import org.junit.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.CoreMatchers.*;
public class Experiments {
@Test
public void REPL() {
}
}
私がJavaで開発している時は、テストクラスの中のstaticな内部クラスから始めることが多いのですが、それは1ファイルにつき1クラスというJavaの要件が好きではないからです。コードに自信がある時に、リファクタリングして内部クラスを外へ出すということは取るに足りません。この投稿で挙げる例では、すべて内部クラスの形を使っていますが、それは重要なことではありません。REPLを使えばトップレベルクラスを組み合わせるのは簡単でしょう。
簡単に達成できる目標:これらのテストを型で置き換える
これから2次元のポイントを扱う機能を作っていく様子を実演したいと思います。以下の例にあるコードやテストはごく単純なものですが、どの問題も本番コードで見かけたものです。目標があまりにも簡単なので、何の苦もなく達成できると考える人もいるかもしれません。ですが、テストを型で置き換えられないかと考えてみることで、型をもっと高度に使いこなせるようになるはずです。
クラス存在テストを型で置き換える
私はまず、ポイントを構築することが必要だと考えて、次のようにテストを書きます。
Point p = new Point();
assertThat(p, is(not(nullValue())));
このテストを成功させるのは簡単ですが、いったん成功したら、どうやって失敗させられるでしょうか。何をテストしているのでしょうか。クラス名のスペルを間違えることはできません。コンパイルできなくなるからです。コンストラクタを間違って呼び出すこともできません。やはりコンパイルできなくなるからです。Javaコンストラクタからnullを得ることもできません。なぜなら、ランタイムがそのことを保証しているからです。
このテストを失敗させることはできないので、削除します。
このようなステップを何回か繰り返して、REPLは以下のようになりました。
package com.atomicobject;
import org.junit.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.CoreMatchers.*;
public class Experiments {
static public class Point {
Integer x, y;
public Point(Integer x, Integer y) {
this.x = x;
this.y = y;
}
}
@Test
public void REPL() {
}
}
インターフェース存在テストを型で置き換える
今度はポイントのxの値にアクセスする必要があるので、こんなひどいテストを書いてみました。
Point p = new Point(0, 0);
assertThat(p, hasProperty("x"));
このテストもひどいですね。
Point p = new Point(0, 0);
assertThat(p.getX(), is(instanceOf(Integer.class)));
メソッドを書き終え、テストに成功したとして、メソッドの有無を確認するテストが役に立つでしょうか。メソッドのスペルが間違っていれば、コードはコンパイルされないでしょう。メソッドの名前を変更したり、メソッドを削除したりした場合も同様です。
型ならこうしたエラーをどんなテストよりも正確に検出してくれるので、このテストは削除します。
データフローテストを不変型で置き換える
Javaでは、クラスにgetterとsetterのコードが埋め尽くされる場合があります。データをあちこち動かし、オブジェクトの状態を変更するための大量のコードを見ることは珍しくありません。ただコードの見栄えが悪いからといって、クラスの大部分のためのテストを記述しないことは許されるでしょうか。
static public class Point {
private Integer x, y;
public Point(Integer x, Integer y) {
this.x = x;
this.y = y;
}
public Integer getX() {
return x;
}
public void setX(Integer x) {
this.x = x;
}
public Integer getY() {
return y;
}
public void setY(Integer y) {
this.y = y;
}
}
getterとsetterのためのテストの記述は省略できたとしても、それ以外のデータフローテストはやはりあった方がいいでしょう。以下のテストは、getterが適切にコンストラクタ引数を返すかどうかをチェックするためのものです。
Point p = new Point(0, 0);
assertThat(p.getX(), is(0));
テストは成功しますが、ここまで不格好なコードが必要でしょうか。ごちゃごちゃした設計を簡略化することでテストを削除できるでしょうか。
型は状態やデータフローが変わる問題を検出しようとする場合、テストよりも効果的です。getterとsetterの間接参照を削除し、ポイントを不変にします。
public class Point {
public final Integer x, y;
public Point(Integer x, Integer y) {
this.x = x;
this.y = y;
}
}
データフローテストを削除できるようになりました。
これによって設計のカプセル化が壊れることはありません。getX()は単にxを記述するための別の方法であるためカプセル化を提供しません。下位互換性、特にバイナリレベルの下位互換性のあるモジュールをリリースする必要がある場合は、ある程度の間接参照が必要になりますが、単にgetterメソッドが使用されているというだけでその設計が将来も古くならないと自分に言い聞かせるのはやめましょう。
エラーのテストから設計のまずさが分かる場合がある
Javaのコードにはnull値のバグが含まれることが多いので、そのnullがコードを壊していないかを検証するテストを書いてみます。
Point p = new Point(null, 0);
assertThat(p.x, is(instanceOf(Integer.class)));
予想通り、テストは失敗します。コンストラクタの呼び出しは正しいでしょうか。null値を渡す許可を受けるべきでしょうか。その必要はありません。nullが使われることがないように、@Nonnullのアノテーションを追加してみます。
public class Point {
public final @Nonnull Integer x, y;
public Point(@Nonnull Integer x, @Nonnull Integer y) {
this.x = x;
this.y = y;
}
}
テストが有効ではなくなったので、削除することができます。しかし、より簡潔で確実にnull値のバグを排除する方法はないでしょうか。あるんです。単にアノテーションに不正だと書くのではなく、プリミティブ型を使ってnullを表現できないようにします。
public class Point {
public final int x, y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
私はユーザエラーを発生し得なくするために型安全性を追求していましたが、コードは、ベストプラクティスだと広く考えられているJavaのプリミティブ型に行き着きました。
このコードはこれまで書いてきたどのコードと比べても短く、簡潔で、バグの可能性も低くなっています。私がこのコードに行き着いたのは優れたテストを選んだからではなく、どのテストにも疑問を持ち、できるだけ多くの型システムを使おうとしたからです。
100%のコードカバレッジでは不十分。型もカバーすること
コードのどの程度がテストされているかを測ることは非常にお勧めです。100%のカバレッジはゴールとして十分ではないという話をよく聞きます。驚くことに、100%のカバレッジでもコードにはたくさんのバグが含まれている可能性があるのです。型を使えば、バグが隠れていそうな場所をテストで見つけやすくなります。ここで、フィボナッチ数列のコードを見てみましょう。
package com.atomicobject;
import org.junit.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.CoreMatchers.*;
public class FibonacciSeqTest {
static class FibonacciSeq {
int nth(int n) {
if (n == 0) return 0;
else if (n == 1) return 1;
else return nth(n-1) + nth(n-2);
}
}
@Test
public void fibonacci_numbers_are_computed() {
FibonacciSeq fib = new FibonacciSeq();
assertThat(fib.nth(0), is(0));
assertThat(fib.nth(1), is(1));
assertThat(fib.nth(2), is(1));
assertThat(fib.nth(3), is(2));
assertThat(fib.nth(4), is(3));
assertThat(fib.nth(5), is(5));
assertThat(fib.nth(20), is(6765));
}
}
このコードは適切ですが、テストはひどい内容です。最初の3つのテストは多分設計を導くものでしょう。最後の4つのテストからは新たな情報が得られませんが、これを見るとシステムが十分にテストされているように勘違いする可能性があります。コードは何度もテストされた100%のカバレッジで、バグなど1つもないと。実際は大違いです。このプログラムでは想定される入力値の、たった0.0000001%でしか正しく動作しません。では、どのテストを残すべきでしょうか。新たに加えるとしたらどのようなテストがいいでしょうか。
Javaの整数は32ビットマシンの整数であり、オーバーフローの可能性があります。フィボナッチ数はすぐに大きくなり、オーバーフローを起こします。このコードではどの時点でオーバーフローが起きるでしょう。以下は失敗するテストです。
assertThat(fib.nth(47), is(2971215073));
46を超えることができない型を定義できる場合、整数をその型で置き換えることができます。Javaにはそのような機能はないため、コードにアサーションを追加し、その制限について記述します。
Javaの整数は負数である場合もあります。負数を入力するとどうなるでしょうか。これはフィボナッチ数列では意味さえ成しません。負のnthは何を意味するでしょうか。以下も失敗するテストです。
assertThat(fib.nth(-1), is(0));
Javaには非負整数を扱う機能がないため、同じ方法を使って入力が非負数であることをアサーションし、内容を更新します。最終的なコードがこれです。
package com.atomicobject;
import org.junit.Test;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.CoreMatchers.*;
public class FibonacciSeqTest {
static class FibonacciSeq {
int nth(int n) {
assert n >= 0 && n <= 46;
if (n == 0) return 0;
else if (n == 1) return 1;
else return nth(n-1) + nth(n-2);
}
}
FibonacciSeq fib = new FibonacciSeq();
@Test
public void fibonacci_numbers_are_computed() {
assertThat(fib.nth(2), is(1));
}
@Test(expected = AssertionError.class)
public void fibonacci_sequence_starts_with_nth_0() {
fib.nth(-1);
}
@Test(expected = AssertionError.class)
public void fibonacci_numbers_wont_silently_overflow() {
fib.nth(47);
}
}
型を定義することでこれらのエラーの可能性を排除することはできませんでしたが、型を慎重に見ることでより正確なコードに近付けることができました。
コードを書く準備はできましたか。 パート2 では、Javaでより高度な型を作成する方法を紹介した後、Kent Beckの著書にあるお金の例について、より正しく型付けされたコードを使って確認し、全体をまとめています。
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa