テスト駆動開発の練習

テストしながら作るということ

 アジャイルとかXPとか言われてもう久しいのですが、日本の開発現場にはまだまだ浸透していないようです。僕も仕事でやらしてもらうのは不可能っぽいんで、一人でやってみました。お題は、アジャイル開発の例でよく使われる「ボーリングのスコア計算」です。

ボウリングのスコア計算ルール

 ボウリングをやったことをある人は分かると思いますが、あの計算方法は他に類を見ない独特のもので、それがある意味単純なボウリングという競技を熱くさせている要因に思えます。それは以下のようなものです。
  1. 基本的に投げるごとに倒したピンの数の和がスコア
  2. ただし、1投目で倒せなかったピンがあったら、2投目を投げる。
  3. 1投目で倒したピンの数が10なら、ストライクとなり、その次、その次の次に倒したピンの数をストライクの点に加算する。
  4. 1投目がストライクでなく、2投目で倒したピンの数の和が10の場合はスペアとなり、その次に倒したピンの数をスペアの点に加算する。
 とにかくストライクやスペアを連続して取ればそれだけ得点がどんどんあがっていくというしくみで、ある意味確変みたいなところがあるわけです。変動してるのは確率じゃありませんが。
 これ以外にも全部ストライクだったら300点とか色々ありますが、今回は割愛。

この計算をするプログラムをTDDで作る

 テスト駆動なので、とりあえずテストから書き始めよう。何でもいいのだが結果をチェックする関数をとりあえず書く。
let Check i n m = if n = m then printfn "(%3d) Passed." i
                           else printfn "(%3d) !!! Check Failure %d <> %d !!!" i n m
 intしかチェックできないが、今回はこれで十分。で、関数のインターフェースと、その関数が満たすべき条件をとりあえず書く。動かなくても書く。チェック条件は最初から全て用意しない。とりあえず成功しそうなものと失敗しそうなものを一つずつ書く。
let rec BowlingGame l = 0

Check 1 (BowlingGame []) 0
Check 2 (BowlingGame [1]) 1

 書いたらすぐに動かす。
(  1) Passed.
(  2) !!! Check Failure 0 <> 1 !!!

 今のところ0しか返さないプログラムなので、チェック1は通過するがチェック2はエラーになる。
 エラーになったので、チェック2を通過するようにBowlingGameを実装する。
let rec BowlingGame l = 
  match l with
  | [] -> 0
  | h :: t -> h + (BowlingGame t)
 単にリストの和を求めるようにしただけだ。書いたらすぐに動かす。
(  1) Passed.
(  2) Passed.
 動いたので、チェックを追加してみる。
Check 3 (BowlingGame [2;3]) 5
 すぐに動かす。
(  1) Passed.
(  2) Passed.
(  3) Passed.
 大丈夫そう。

 こんなことを繰り返してプログラムを作っていく。

ボウリングのスコア計算の面白いところ

 ここからボウリングっぽいところに踏み込んでいきます。まずストライクのルールを追加します。ストライクが一つあると、その次の点と次の次の点がストライクの点(10)に加算され、最初の1投がこの場合12点になります。さらに、1点を2回加算して14点にならなければなりません。
 四番目のテストを追加します。
Check 4 (BowlingGame [10;1;1]) 14
 実行すると当然エラーになります。
(  1) Passed.
(  2) Passed.
(  3) Passed.
(  4) !!! Check Failure 12 <> 14 !!!
 これが通るようにプログラムを直していかなければいけません。
 ボウリングのスコアは、ストライクやスペアが出るとその得点がその時点では決まらず、未来の然るべき時点まで計算が延期されます。これはちょっと関数型っぽいぞと。遅延させればいいんだなフムフム・・・などと考えます。
type Score =
  | Just of int
//  | Spair of (int -> int)
  | Strike of (int -> int -> int)
  

let rec take n filler (l: 'a list)  =
  match n with
  | 0 -> []
  | n -> match l with
         | [] -> filler :: (take (n - 1) filler [])
         | h :: t -> h :: (take (n - 1) filler t)

let CreateCalculation = 
  List.map(fun x -> 
             match x with
             | 10 -> Strike (fun n m -> n + m + 10)
             | v -> Just v
          )
let value s =
  match s with
  | Just a -> a
  | Strike f -> f 0 0

let rec CalcIt (l : Score list) =
  match l with
  | [] -> 0
  | h :: t ->
    let curr :: next :: nextnext :: [] = take 3 (Just 0) l
    match curr with
    | Just v -> v + (CalcIt t)
    | Strike f -> (f (value next) (value nextnext))  + (CalcIt t)
  

let BowlingGame l = 
  let l1 = CreateCalculation l
  CalcIt l1

 なにやら急に怪しげな雰囲気になってきましたが・・・・

 最初のScoreは、ポイントのタイプを表しています。単に(Just)点をもらっただけのものと、Strikeをとったものと二通り。それ以外にSpairというのがありますが、これは後で考えます。Strikeは、二つの引数が決まって初めて点が決まりますから、intを二つとってintを返す関数となるはずです。
 次のtakeは、リストの任意の位置から三つを取り出して返す関数ですが、要素が足りないとき、末尾にfillerを埋めるようになっています。これによって、最後にストライクを取ったときでも次の値がなくて困ることがなくなります。
 CreateCalculationは、ピンを倒した数のリストから、得点にあたるScoreのリストを返す関数です。10点ならStrikeを返し、それ以外ではJustを返すようになっています。
 valueは、Scoreから単に倒したピンの数を求める関数です。
 そしてCalcItが、実際のスコアを計算する関数です。一つも投げてないならスコアはゼロ。投げているなら、現在の位置から3投ぶんのリストをtakeで取り出して、そこからスコアを計算しています。
 これで先ほどの四つのチェックを流すと、
(  1) Passed.
(  2) Passed.
(  3) Passed.
(  4) Passed.
 このように、テスト4も通過するようになりました。念のために一つテストを追加しても
Check 5 (BowlingGame [10;10;1;1]) 35
(  1) Passed.
(  2) Passed.
(  3) Passed.
(  4) Passed.
(  5) Passed.
 大丈夫そうです。ちなみに、何かやるたびに必ず全部テストを流していますが、これは無駄のように見えて必要なことですので、「なんかやるたびにテストを全部流す」癖を付けておきましょう。
 さて、一応動くには動いたのですが、このプログラムはまだまだ再考の余地があります。一番醜いのは、CalcItの中のlet束縛が、全ての条件を尽くしていないため警告を出すことです。take関数の出力は要素が三つあることを保証してくれているのですが、そんなことはコンパイラは分からないので警告を出すのです。takeという関数も出来れば使いたくないですし、冷静に考えると、int->int->intの関数作って後で実行、というのも作ってみればいたずらにプログラムを読みにくくしているだけで、あんまり意味があるように感じません。
 また、Spairに対応するためにプログラムを加工する必要がありますので、せっかく動くようになったプログラムですが、ごっそり書き換えてみましょう。その前に、スペアを含んだテストケースを追加します。
Check 6 (BowlingGame [5;5;3;1]) 17

まずテストを書いて、それがエラーになることを確認してから実装を始めることも癖にしておきましょう。
(  1) Passed.
(  2) Passed.
(  3) Passed.
(  4) Passed.
(  5) Passed.
(  6) !!! Check Failure 14 <> 17 !!!

 というわけで実装に入ります。

type Score =
  | Just of int
  | EoT of int
  | Spair of int
  | Strike of int
  

let CreateCalculation list = 
  let ret, l = List.fold (fun (pre, l) x -> 
                 let curr = 
                   match pre with
                   | Just n -> if n + x = 10 then Spair x else EoT x
                   | Strike _ | Spair _ | EoT _ -> if x = 10 then Strike x else Just x
                 curr, curr :: l
               ) (EoT 0, []) list
  l

let value s =
  match s with
  | Just a | Spair a | EoT a | Strike a -> a

let rec CalcIt (l : Score list) =
  let calc nn n c =
    match c with
    | Just v | EoT v -> v
    | Spair v -> v + (value n)
    | Strike v -> v + (value n) + (value nn)
  match l with
  | nextnext :: next :: curr :: [] -> 
    calc nextnext next curr 
  | nextnext :: next :: curr :: t ->
    (calc nextnext next curr)  + (CalcIt (next :: curr :: t))
  | _ -> failwith "Error Case."
  
let BowlingGame l = 
  CreateCalculation l |>
  (@) [EoT 0;EoT 0;EoT 0] |>
  CalcIt 

Check 1 (BowlingGame []) 0
Check 2 (BowlingGame [1]) 1
Check 3 (BowlingGame [2;3]) 5
Check 4 (BowlingGame [10;1;1]) 14
Check 5 (BowlingGame [10;10;1;1]) 35
Check 6 (BowlingGame [5;5;3;1]) 17
 このプログラムの細かいことを話すのはこの回の本意ではないので割愛しますが、takeや尽くされていないmatchが無くなっているのがわかります。EoTというのが出てきていますが、これは2投目でスペアが取れなかったことをあらわします。また、Strikeが10を持つようになっていますが、これはvalueが少しシンプルになるからです。
 もう一つの大きな変更は、CreateCalucationが逆順のリストを返すようにしたことです。foldでリストを出力すると普通リストは逆順になってしまいますが、使う側のmatchで逆順に取り出すようにしています。nextnext :: next :: curr :: []がそれです。
 これを実行すると、
(  1) Passed.
(  2) Passed.
(  3) Passed.
(  4) Passed.
(  5) Passed.
(  6) Passed.
 となり、うまく動いていることがわかります。一つパターンを増やしてみましょう。
Check 7 (BowlingGame [0;10;3;1]) 17     // (0+(10+3)+3+1)
 1投目がガーターになり、2投目で10本倒れたパターンですが、これがストライクにならないようになっているかを確認したいのです。
(  1) Passed.
(  2) Passed.
(  3) Passed.
(  4) Passed.
(  5) Passed.
(  6) Passed.
(  7) Passed.

 大丈夫そうです。うまくいったら、さらにテストケースを追加し、うまくいかないパターンを見つけたらこれ幸いにと実装を直すのです。

テスト駆動開発(TDD)の意義

 この拙文で、TDDのうまみが伝わったでしょうか。僕が思うにTDDの一番のメリットは「変更に強い」というか「変更に怯える気持ちが薄くなる」というところだと思います。
 テストが手動であったり、後でテストプログラムを書いていると、出来上がったプログラムが「唯一無二」の存在に思えてきます。
 しかしTDDで作ると、出来たプログラムは「最初に書いたテストを満たすプログラムの(無数にある実装の)一つでしかない」という気になってきます。大切なのはテストであって、それを満たすものなら何でもかまわないのです。そういう気持ちになると、一度出来た風に見えるプログラムを大胆に改変することに対する抵抗がなくなり、常によりよいプログラムの形を求め続けるモチベーションを維持できます。

 テストは右足、実装は左足だと思ってください。まず右足から、交互に前に出すことで恐れずに前に進んでいくことができます。左足が浮いている間、右足が地に着いているからこそ、安心して左足を出すことが出来るのです。右足がなくとも人は前に進めなくはないですが、なかなか進みたいところに進めるものではありません。一度目的地に着いたら、同じ場所でケンケンを続けるのも大変です。しかし、両足あれば全く同じ場所でずっと足踏みすることはできます。自分の足の周りは全部切り立った崖、というところでケンケンをするのは僕も嫌ですが、足踏みなら出来るかもしれません。

 上にあげた最後のプログラムはベストのものでは恐らくありません。「もっといい方法やきれいな形を思いついた」ら、すぐに書き直してかまわないのです。参照透明な世界では、単体テストの結果が変わらなければ外から見れば同じプログラムと考えてもいいのです(そういうテストを書かないといけないのですが)。

TDDの欠点

 と言うようにいい事尽くめな感じもするTDDですが、日本の現場でいまひとつ普及しないのには、僕が思うに理由があります。それは、テストが妥当であるかを、プログラマでなければ検証できない、というところです。いくらテストの数を100、200とそろえたところで、意味もなくダラダラとケースが増えただけではプログラムの精度があがっていきません。「よいTDD」では、先ほどの例のように「通らないテストを書く」→「通るように直す」→「また通らないテストを必死で探す」→「直す」の繰り返しで成り立つものですが、単に出来上がったプログラムと書かれたテストの件数を見ただけでは、それがどれほど意味のある数なのか、検証できないのです。要するに「エライ人」が納得できない、もしくは「エライ人が納品先にソフトウェアの品質を数字で示せない」ということになります。
 TDD以前はどうしていたかというと、テストケースを書いて、プログラムができるまで流すのを我慢して(もしくはプログラミングの後でチェックリストを書いて)、出来上がってからテストをして、出たバグの数がテストの品質の指標になっていたわけです。ステップあたりのバグの割合の目安が工程ごとにあって、それよりあまり多いとプログラムの品質、少ないとテストの品質を疑う。そんなやり方で、数字を使ってソフトウェアやテストの品質を評価できたのです。実際のところこれがどれほど意味があるのかは置いといて、このやりかたが一応は受け入れられている現場にTDDを持ち込もうとすると、このあたりの上司からの質問に答えなければなりません。
 TDD推進の側からのこの問いに対する答えは「ペアプログラミング」だと思います。必ずペアで一つの作業をすることで、常に片方が作業の品質を監視できるような体制があって、初めてTDDは成立するのかもしれません。しかしペアプログラミングを日本に普及させるのは、TDDを普及させるよりももっと大変かもしれない・・・・

 とにかくTDD自体は一人でも始められますし、一人でやっても十分に成果が得られるものです。この話がなんとなく理解できたら、ぜひ自分で何か小さいプログラム(ソートとかリスト操作など)を、TDDで作ってみてください。TDDが「場当たり的に作ってる」というようなものではない手ごたえが感じられると思います。

(文責:片山 功士  2012/02/08)

今日: -
昨日: -
トータル: -
最終更新:2012年02月09日 00:42