#126 NaturalSpec 実践入門

はじめに

この記事は F# Advent Calendar 2012 の2日目です。
2012年のトップバッターは昨年と同じく @bleis さんが務め、C#er にとっても実用的な素晴らしい記事を書いていただきました。

C# から使いやすい F# コードの書き方 - ぐるぐる〜

F# と C# の相互運用時に便利なアイディアがたくさん載っており、僕も使ってみたいものがいくつもありました!(・∀・)

NaturalSpec

今夏、あるプロダクトのチュートリアルに、素晴らしい翻訳が提供されました。

NaturalSpec チュートリアル翻訳版 - FsNinja

こんな素晴らしい翻訳記事を書いてくれた @smallgeek さんに、同じ F#er として心から感謝します。

この NaturalSpec は、F# 製の BDD スタイル テスティング・フレームワークです。

forki / NaturalSpec - GitHub

テストは F# で書きますが、テスト対象は F# だけでなく VB や C# であっても大丈夫です。
私も F# で TDD するときには、このテスティング・フレームワークを使います。

本エントリでは、テスト駆動開発による写経形式で NaturalSpec を使ってみます。
なお、本記事は次の素晴らしい記事にインスピレーションを受けて書きました。

RSpec の入門とその一歩先へ - t-wadaの日記

お題と環境

今回は「みずぴー」こと MotsunabeZombieProject という TDDBC 福岡のお題を使います。

MotsunabeZombieProject

開発環境は次の通りです。
  • Visual Studio 2010 SP1
  • NaturalSpec (commit: bdaca46db274b1d50664a6f2da158a24532ca672)
  • NUnit 2.5.10

作業開始

まずはソリューションを作成します。
ソリューション構成は以下のような感じ。
MotsunabeZombieProject.sln
└ MotsunabeZombieProject.fsproj
  └ Module1.fs … のちに MZP.fs にリネーム
└ MotsunabeZombieProject.Specs.fsproj
  └ Module1.fs … のちに MZP.Scenario.fs にリネーム
lib/ … NaturalSpec.dll と NUnit.Framework.dll を格納
.hgignore
また、 MotsunabeZombieProject.Specs.fsproj に NUnit.dll と NaturalSpec.dll の参照を追加し、テスト対象である MotsunabeZombieProject をプロジェクト参照しておきます。

Mercurial で管理

さて、今回のコードは Mercurial を使って管理しました。
今回は自作の hg now を使ったので、コミットコメントは有用ではありませんが、ソースコードがどのように変わったかの履歴は追いかけられると思います。

このソース一式(今回の範囲以降のコミットも含まれています)は BitBucket にアップしています。

Gab_km / MotsunabeZombieProject - BitBucket

出来上がったコミット群を見て、これなら言語は違えど こっち を見てもらった方が勉強になっていいのでは…と思ったのは内緒です(´・ω・`)

以下、各作業ではどのコミットが対応するか、ハッシュ値(先頭7桁のみ)を記しておきます。

テストの自動化

また、今回は post build イベントにテストの実行を仕込んでおきました。
MotsunabeZombieProject.Specs.fsproj の[プロパティ] -> [ビルド イベント] -> [ビルド後イベントのコマンドライン]に、
"$NUNIT_HOME\bin\net-2.0\nunit-console.exe" MotsunabeZombieProject.Specs.dll
(`$NUNIT_HOME` は、お使いの NUnit がインストールされたフォルダのパス)
と記述しておくことで、ビルドするたびに NaturalSpec が実行されるようになります。

シナリオを書く

commit: 4b8eea5

では、さっそくチケット #1 から進めていきましょう。

コミットの前に ticket/#1 というブランチを切りました。
以降も大きくチケットごとにブランチを切り、必要な場合はさらに細かいブランチを切っていきます。

MZP.Scenario.fs
+module MZP.Scenario
+
+open NaturalSpec
+open MZP
+
+[<Scenario>]
+let ``replyでもmentionでもhash tagでもないTweetを普通のTweetと判定する`` () =
+ Given "Alice\tあいうえお"
+ |> When categorize
+ |> It should equal "Normal\tあいうえお"
+ |> Verify
NaturalSpec では、上記コードのようにパイプライン演算子(|>)でシナリオを構成していきます。
その他、詳しい書き方は前述した NaturalSpec チュートリアル翻訳版 を参照してください。

実装コードを書く

commit: 18de5e5

このテストは、テスト対象となるコードがないので落ちます。書きましょう。

MZP.fs
+module MZP
+
+let categorize tweet = "Normal\tあいうえお"
MZP.fs の実装がアレすぎますが、これはテストを通すためのもっとも簡単な実装で、仮実装と呼ばれます。

テストのグルーピング

commit: 34079e4

ここで、今後のチケット消化でテストが混雑してくると考えられるので、テストをモジュールでグルーピングします。

MZP.Scenario.fs
 open NaturalSpec
 open MZP
-[]
-let ``replyでもmentionでもhash tagでもないTweetを普通のTweetと判定する`` () =
- Given "Alice\tあいうえお"
- |> When categorize
- |> It should equal "Normal\tあいうえお"
- |> Verify
+module ``replyでもmentionでもhash tagでもないTweetを普通のTweetと判定する`` =
+
+ [<Scenario>]
+ let ``Aliceが"あいうえお"とツイート`` () =
+        Given "Alice\tあいうえお"
+        |> When categorize
+        |> It should equal "Normal\tあいうえお"
+        |> Verify
私の観測範囲では、モジュールでテストのグルーピングをする人が多いですが、クラスを使ってのグルーピングも可能です。

三角測量

commit: af57172

テストを追加しましょう。

MZP.Scenario.fs
         Given "Alice\tあいうえお"
         |> When categorize
         |> It should equal "Normal\tあいうえお"
+        |> Verify
+
+    [<Scenario>]
+    let ``Bobが"かきくけこ"とツイート`` () =
+        Given "Bob\tかきくけこ"
+        |> When categorize
+        |> It should equal "Normal\tかきくけこ"
         |> Verify
MZP.fs が仮実装のままなので、テストが失敗するはずです。

ちゃんとやる

commit: 5b00e96

実装を修正しましょう。

MZP.fs
 module MZP

-let categorize tweet = "Normal\tあいうえお"
+let categorize (tweet : string) = "Normal\t" + tweet.Split([|'\t'|]).[1]
これでビルドが通るようになるはずです。

default にマージ

commit: ab67579

これでチケット #1 は実装出来たと判断したので、default ブランチにマージします。

ここまでのコードです。

MZP.fs
module MZP

let categorize (tweet : string) = "Normal\t" + tweet.Split([|'\t'|]).[1]
MZP.Scenario.fs
moduleMZP.Scenario

open NaturalSpec
open MZP

module ``replyでもmentionでもhash tagでもないTweetを普通のTweetと判定する`` =

    [<Scenario>
    let ``Aliceが"あいうえお"とツイート`` () =
        Given "Alice\tあいうえお"
        |> When categorize
        |> It should equal "Normal\tあいうえお"
        |> Verify

    [<Scenario>]
    let ``Bobが"かきくけこ"とツイート`` () =
        Given "Bob\tかきくけこ"
        |> When categorize
        |> It should equal "Normal\tかきくけこ"
        |> Verify

チケット #2


commit: 2d284be

チケット #2 に着手します。
さっそく ticket/#2 ブランチを切りましょう。

MZP.Scenario.fs
         Given "Bob\tかきくけこ"
         |> When categorize
         |> It should equal "Normal\tかきくけこ"
+        |> Verify
+
+module ``hash tagを含むTweetを判定する`` =
+
+    [<Scenario>]
+    let ``Aliceが"#tddbc"とツイート`` () =
+        Given "Alice\t#tddbc"
+        |> When categorize
+        |> It should equal "!HashTag\t#tddbc"
         |> Verify
実装が無いので、テストは予定通り失敗します。

明白な実装

commit: f560f23

MZP.fs
 module MZP
-let categorize (tweet : string) = "Normal\t" + tweet.Split([|'\t'|]).[1]
+open System.Text.RegularExpressions
+
+let categorize (tweet : string) =
+    let [|name; body|] = tweet.Split([|'\t'|])
+    if Regex.IsMatch(body, "#\w*") then "!HashTag\t" + body
+    else "Normal\t" + body
ひと思いに実装しました。自信があるときは仮実装からの三角測量を行わず、明白な実装を行います。

…と思ったら

commit: 4e4de8f

さて、'#' を含んでいますがハッシュタグと判定されない文字列のテストを書いてみましょう。

MZP.Scenario.fs
         Given "Alice\t#tddbc"
         |> When categorize
         |> It should equal "!HashTag\t#tddbc"
+        |> Verify
+
+    [<Scenario>]
+    let ``Bobが"さしすせそ#tddbc"とツイートするとNormal判定`` () =
+        Given "Bob\tF#tddbc"
+        |> When categorize
+        |> It should equal "Normal\tF#tddbc"
         |> Verify
…あっ、テストが失敗しました(´・ω・`)

(本題とは無関係ですが、シナリオ名と実際に Given に渡している値が違うのに気が付きましたか?これは「もっと良いテストデータはないかな」とあれこれ考えているうちに、シナリオ名を修正するのを忘れていたのです…)

あわてず修正

commit: 4948ef3

とても残念な結果になってしまいましたが、これもテストを書いていたおかげで気が付いたことです。
もちろん、先ほどの実装を仮実装と言ってしまえば、何事もなかったかのように進めることが出来ます。

MZP.fs
 let categorize (tweet : string) =
     let [|name; body|] = tweet.Split([|'\t'|])
-    if Regex.IsMatch(body, "#\w*") then "!HashTag\t" + body
+    if Regex.IsMatch(body, "(^|\s)#\w*") then "!HashTag\t" + body
     else "Normal\t" + body
正規表現にしてやられましたが、これでテストは成功するはずです。

ちょっとリファクタリング

commit: e4e7628

さて、ちゃんと実装出来たようなので、ここでリファクタリングです。

MZP.Scenario.fs
@@ -6,14 +6,14 @@
 module ``replyでもmentionでもhash tagでもないTweetを普通のTweetと判定する`` =
     [<Scenario>]
-    let ``Aliceが"あいうえお"とツイート`` () =
+    let ``Aliceが"あいうえお"とツイートするとNormal判定`` () =
         Given "Alice\tあいうえお"
         |> When categorize
         |> It should equal "Normal\tあいうえお"
         |> Verify

     [<Scenario>]
-    let ``Bobが"かきくけこ"とツイート`` () =
+    let ``Bobが"かきくけこ"とツイートするとNormal判定`` () =
         Given "Bob\tかきくけこ"
         |> When categorize
         |> It should equal "Normal\tかきくけこ"
@@ -22,7 +22,7 @@
 module ``hash tagを含むTweetを判定する`` =

     [<Scenario>]
-    let ``Aliceが"#tddbc"とツイート`` () =
+    let ``Aliceが"#tddbc"とツイートすると!HashTag判定`` () =
         Given "Alice\t#tddbc"
         |> When categorize
         |> It should equal "!HashTag\t#tddbc"
テストに対するリファクタリングですが、もうちょっと情報豊富にしてみました。
テスト内容自体は変更していませんが、分かりやすくなったと思います。

再びマージ

commit: 4caf5cc

チケット #2 の実装は完了したので、default ブランチにマージします。

完了時点のコードを記載します。

MZP.fs
module MZP

open System.Text.RegularExpressions

let categorize (tweet : string) =
    let [|name; body|] = tweet.Split([|'\t'|])
    if Regex.IsMatch(body, "(^|\s)#\w*") then "!HashTag\t" + body
    else "Normal\t" + body
MZP.Scenario.fs
module MZP.Scenario

open NaturalSpec
open MZP

module ``replyでもmentionでもhash tagでもないTweetを普通のTweetと判定する`` =

    [<Scenario>]
    let ``Aliceが"あいうえお"とツイートするとNormal判定`` () =
        Given "Alice\tあいうえお"
        |> When categorize
        |> It should equal "Normal\tあいうえお"
        |> Verify

    [<Scenario>]
    let ``Bobが"かきくけこ"とツイートするとNormal判定`` () =
        Given "Bob\tかきくけこ"
        |> When categorize
        |> It should equal "Normal\tかきくけこ"
        |> Verify

module ``hash tagを含むTweetを判定する`` =

    [<Scenario>]
    let ``Aliceが"#tddbc"とツイートすると!HashTag判定`` () =
        Given "Alice\t#tddbc"
        |> When categorize
        |> It should equal "!HashTag\t#tddbc"
        |> Verify

    [<Scenario>]
    let ``Bobが"さしすせそ#tddbc"とツイートするとNormal判定`` () =
        Given "Bob\tF#tddbc"
        |> When categorize
        |> It should equal "Normal\tF#tddbc"
        |> Verify

もっと便利に

NaturalSpec でテストを書くときは、テストをグルーピングしたり、小さな関数を書いてテスト用 DSL を拡張することで、より使いやすくなります。
例えば、本エントリで紹介しませんでしたが、以下のテスト用コードを MZP.Scenario.fs に追加していました。
let categorizeAndGetCategory = categorize >> (fun category -> category.Category)
これは、途中でツイート判別結果をレコード型として返すように変更したのですが、そこから判別した分類を取得するための関数です。
let be_equivalent_to x y =
    printMethod x
    let containsAllAndNoOthers a b =
        a |> Seq.forall (fun element -> Seq.exists ((=) element) b) &&
        b |> Seq.forall (fun element -> Seq.exists ((=) element) a)
    let found = containsAllAndNoOthers x y
    IsTrue,true,found,y
これは、NaturalSpec に「2つのコレクションが、順序に関係なく同じ要素で構成されている」ことをテストするためのアサーションがなかったため、追加してみました。
…実際にあまり使う場面がなかったのは内緒です(´・ω・`)

まとめ

ごく簡単に、F# + NaturalSpec によるテスト駆動開発の流れを見てきました。
NaturalSpec はテスト実行がちょっと遅い(I/Oアクセスしてるからかな…?)面はありますが、人が読みやすいテストを書くことができ、「仕様をテストの形で書き下す」ことができます。

NaturalSpecはこれ自体が非常に実用的であり、より F# を実用たらしめるライブラリだと言えます。

明日の Advent Calendar は

3日目は eozw さんの F# + ExcelDna + R.NET です。

トラックバックURL

#126 NaturalSpec 実践入門 へのトラックバック

まだトラックバックはありません。

#126 NaturalSpec 実践入門 へのコメント一覧

まだコメントはありません。

コメントする

#126 NaturalSpec 実践入門 にコメントする
絵文字
プロフィール
あわせて読みたい
あわせて読みたい
記事検索
Project Euler
なかのひと
アクセス解析
Coderwall
  • ライブドアブログ

トップに戻る