お題その1
お題その1:入力として、野球選手の打席数と打数と安打数を受け取り、選手の打率を計算できること。 打率は少数第四位で四捨五入すること。
※打率=安打数/打数
※打数とは、打席に立った数のうち、打率の計算に含まない打席を引いたもの。
さっそくテストケースを書き始めたいところですが…。
「TDDでは事前設計しない」というのは都市伝説です。 テストケースを書き始めるために必要な設計は、(頭の中だけかもしれませんが)ちゃんと実施します。実際の開発プロジェクトでは、アーキテクチャの検討はもちろんのこと、おおまかなクラスやメッセージの定義などはやっておくべきです。それから、今回のお題のようなメソッドレベルの設計へと進んでいきます。
メソッドの外部設計
このお題では、どんなもの(what)を作ればよいでしょうか? 打率を計算するメソッドを作ればよいですね。名前は「Calc打率」にしましょう。
C#ではメソッドだけでは存在できず、その入れ物であるクラスも必要です。どこに入れましょう? TDDでは、迷ったらとりあえずシンプルにしておきます。「野球選手」オブジェクトだけを作って打率を計算することにします。
例えば「打撃データ」オブジェクトとして「野球選手」オブジェクトから独立させた方がいいかも、と思ったかもしれません。まずはシンプルに作ることを優先して、そう思ったことは「//TODO:」コメント※6として残しておきましょう。
他にメソッドを定義するために必要な情報は? 引数と返値の型を決めないといけませんね。引数は、整数でよいでしょう。返値は、厳密な小数点数を扱いたいでしょうから、decimal型にしておくべきです。
また、このメソッドの反応も定義します。このお題ではとてもシンプルで、引数を渡すと打率が返ってくるという反応だけです。…あ、ゼロ除算が起こりえますね、どうしましょう? これもシンプルに考えましょう。つまり、ゼロ除算例外が発生するという反応をするんだと思えばいいんです。
作るもの | メソッド「Calc打率」 | ||||||
---|---|---|---|---|---|---|---|
置き場所 | クラス「野球選手」 | ||||||
引数 | int 打席数, int 打数, int 安打数 | ||||||
返値の型 | decimal | ||||||
刺激と反応 |
|
実際には、このような簡単なものなら頭の中だけで済んでしまうでしょう。反応パターン(それぞれがテストケースになり得る)が何通りもあって複雑なときには、「//TODO:」コメントとして先に書いておいた方がよいです。それ以外の項目(メソッド名など)は、テストケースを書くときに考えてもたいてい問題ありません。
お題その1からは、他の引数(打席数と安打数)が反応に及ぼす影響が分からないので、上の表には書いてありません。じつは最初に全部(お題その3まで)検討しておいた方がよいのですが、そのことはこの後の展開を読めば分かっていただけるかと思います。
Visual Studioでは、メニュー[表示]-[タスク一覧]で表示される「タスク一覧」(そのドロップダウンで「コメント」を選択)に、「//TODO」で始まるコメント行がまとめて表示されます。タスク一覧には、デフォルトでは「//TODO」の他に、「//HACK」「//UNDONE」などがピックアップされます(オプションで変更可能)。
最初のテストケース
最初のテストケースを書くときには、上述のようにメソッドのシグネチャ(名称、引数、型など)や置き場所など、メソッドの設計にまつわるたくさんの決定をくださなければなりません。せめて、メソッドの応答はもっとも簡単なものから始めましょう。このお題では、
引数:安打数=0 → 応答:返値(打率)=0.0
というケースが、計算しなくても返値が分かって良いでしょう。
テストファースト
Visual Studioが自動生成した「Class1.cs」を削除して、新しく「野球選手Tests」クラスを作ります。そこにテストケースを次のように記述します。まだ「野球選手」クラスがないのでIDEが警告してきますが、どんどん書いてしまいます。
using NUnit.Framework; namespace TDDBC横浜 { [TestFixture()] public class 野球選手Tests { [TestCase()] public void Calc打率Test01_安打数0のとき_打率0() { //準備 int 打席数=0; int 打数=0; int 安打数 = 0; decimal 期待値 = 0.0m; 野球選手 p = new 野球選手(); //実行 decimal 打率 = p.Calc打率(打席数, 打数, 安打数); //検証 Assert.AreEqual(期待値, 打率); //後始末 // (なし) } } }
テストケースの書き方は、手動テストの手順と同じ要領です。テストに必要な準備をして、テスト対象を実際に実行し、その結果を検証します。そのあと必要ならば後始末をします。
このコードで使っているNUnitの機能は、TestFixture
、TestCase
の2つの属性と、Assert
クラスです。NUnitは属性を見て、テストとして実行するべきメソッドを自動的に抽出します。Assert
クラスには検証するためのメソッドが揃っています。詳細は"NUnit - Documentation"(英文)を参照してください。
さてこのテストケースは、まだ野球選手
クラスを作っていないので、コンパイルするとエラーになります。
コンパイルエラーもテスト失敗と考えます。テストを失敗させることができた(=RED)ので、テストファーストのルールとして、ようやく製品コードを書けます。そこで、野球選手
クラスを作成してコンパイルしてみると、またエラーになります。今度はCalc打率()
メソッドがないという失敗ですからメソッドを作ります。メソッドの中身は…? ごくシンプルに0を返すだけのコードを書けば、上のテストケースにパスできるはずです。
namespace TDDBC横浜 { public class 野球選手 { public decimal Calc打率(int 打席数, int 打数, int 安打数) { return 0.0m; } } }
このように、コードとして正しくないと分かっているけれど、とりあえずテストを通すためだけのコードを書くやり方を「仮実装」(Fake It)と呼びます。
コンパイルできたら、いよいよNUnitの出番です。起動したら、メニュー[File]-[Open Project]を使って、コンパイルできたアセンブリファイル「TDDBC横浜.dll」を読み込ませます。[Run]ボタンでテストケースを実行すると…
…見事、GREEN! 最初のテストケースにパスしました。
リファクタリング
次のテストケースを書く前に、テストコードをリファクタリングしておきましょう。準備のところで引数と期待値を用意していますが、NUnitではTestCase
属性を使って次のように書き変えることができます。
[TestCase(0, 0, 0, 0.0)] public void Calc打率Test01(int 打席数, int 打数, int 安打数, decimal 期待値) { 野球選手 p = new 野球選手(); decimal 打率 = p.Calc打率(打席数, 打数, 安打数); Assert.AreEqual(期待値, 打率); }
何か書き換えたら、NUnitで全部のテストを実行して何も壊していないことを確かめます。さらにテストケース側を変えた場合には、製品コードにわざとバグを入れて、ちゃんとREDになることを確かめると安心できます。ここでは、例えばCalc打率()
の返値を1.0m
に変えてみてREDになれば、リファクタリング後のテストケースもちゃんと働いていると確信できますね。
元に戻してGREENを確かめたら、最初のテストケースは完了です。
打率計算を完成させる
まだ打率の計算を実装していませんね。今はまだ最初のテストケースにパスするための仮実装、すなわち定数を返しているだけです。もう一つ、例えば打率が10割になるはずのテストケースを追加してみましょう。2つのテストケースから製品コードの同じ箇所を照らし出すこのやり方は、「三角測量」(Triangulate)と呼ばれています。
[TestCase(0, 0, 0, 0.0)] [TestCase(5, 3, 3, 1.0)] // ←追加 public void Calc打率Test01(int 打席数, int 打数, int 安打数, decimal 期待値) { 野球選手 p = new 野球選手(); decimal 打率 = p.Calc打率(打席数, 打数, 安打数); Assert.AreEqual(期待値, 打率); }
予定通りREDになりましたでしょうか?
これをGREENにするべく、製品コードを修正します。
public decimal Calc打率(int 打席数, int 打数, int 安打数) { return 安打数 / 打数; // ←変更 }
さぁ、これでオールGREEN …あれっ!? さっきGREENだった最初のテストケースがREDになっています。これはどうしたことでしょう?
[TestCase(0, 0, 0, 0.0)]
テストデータとして打数にも0を渡しています。これが原因でDivideByZeroException
(ゼロ除算例外)が発生したのです。しかしこれは、最初に外部設計のところで考えた通りの応答です。つまり間違えているのは、テストケースの方ですから、そちらを直します。
[TestCase(5, 3, 0, 0.0)] // ←修正 [TestCase(5, 3, 3, 1.0)] public void Calc打率Test01(int 打席数, int 打数, int 安打数, decimal 期待値) { 野球選手 p = new 野球選手(); decimal 打率 = p.Calc打率(打席数, 打数, 安打数); Assert.AreEqual(期待値, 打率); }
オールGREENを確認したら、テストファーストは完了です。リファクタリングすべきところを、ざっと探します。今回は特になさそうなので、2つめのテストケースも完了です。
不安をテストに
今回は「偶然に」ゼロ除算例外が発生したので、外部設計で考えていたすべての反応を見ることができました。もしも最初から正しい引数(打数に0ではない数)を与えていた場合は、どうでしょう。ゼロ除算例外が出る反応は見ることができません。また、ゼロ除算例外が出るテストケースをこれから書いても失敗しない(いきなりGREENになる)ので、テストファーストの原則(RED→GREEN)からは外れてしまいます。しかし、本当にゼロ除算例外が出るかどうか不安になったのならば、原則から外れてテストを書けばよいのです。TDDは手段であって目的ではありませんから。
新しくテストケースを追加します。
[TestCase(0, 0, 0)] public void Calc打率Test02_打数0なら例外(int 打席数, int 打数, int 安打数) { 野球選手 p = new 野球選手(); Assert.Throws<System.DivideByZeroException>(() => p.Calc打率(打席数, 打数, 安打数)); }
繰り返しますが、このテストケースは最初からGREENになるので、テストファーストではありません。不安を解消するために、現状のメソッドの仕様を確認するテストです(「仕様化テスト」(Characterization Test)と呼ばれます)。ゼロ除算例外が出ると分かりきっているのなら書く必要はありません。
見落としはないか
一通り完成したところで、見落としていることはないか検討します。製品コードをじっくり読んで、足りないコードがないか考えてみましょう。テストケースに誤りや不足はないでしょうか。仕様書(「お題その1」)も読み直してみましょう。
お題その1を読み返してみると、「打率は少数第四位で四捨五入すること」を忘れていました。テストを追加しましょう。
[TestCase(5, 3, 0, 0.0)] [TestCase(5, 3, 3, 1.0)] [TestCase(5, 9999, 2345, 0.235)] // ←追加 (0.23452345…が四捨五入で0.235になる) public void Calc打率Test01(int 打席数, int 打数, int 安打数, decimal 期待値) { 野球選手 p = new 野球選手(); decimal 打率 = p.Calc打率(打席数, 打数, 安打数); Assert.AreEqual(期待値, 打率); }
「5打席、9999打数」というのはありえない組み合わせです。そのうち打席数と打数の関係についての仕様が明確になって実装したときに、このテストケースは再びREDになるでしょう。そのときに5を10000などに直すも良し、気付いた今のうちに直しておくも良しです。今は、「ありえない組み合わせの引数でも受け付けてしまうメソッドになっているんだ」ということを覚えておきましょう。
REDを確認したら、これにパスするように製品コードを直します。おっと、四捨五入の方式はどうしましょう? とりあえず5は常に切り上げることにして、後で確認するためTODO:コメントに書いておきましょう。
public decimal Calc打率(int 打席数, int 打数, int 安打数) { return Math.Round((decimal)安打数 / 打数, 3, MidpointRounding.AwayFromZero); // ←変更 //TODO: 四捨五入方式は AwayFromZero で良いか? }
この記事を読みながら実際に写経している人は、REDのメッセージを見て、もう一つバグがあったことに気づくと思います。2つめのテストケースでも引数の選び方(5, 3, 3, 1.0)が良くなかったことが原因です。どういう引数にしておけばよかったでしょうか?
これでもう見落としていることはなさそうです。お題その1は完成です。