TDD Boot Camp のお題を C# と Git でやってみた
自分で考えたお題を自分で解くとかそれなんてマッチポンプ・・・
打ち上げ終了後のホテルと、翌日の帰りの新幹線の中で書いたコードを順番に追ってみます。
準備するものは Git で、あるといいものは Visual Studio 2010 と NUnit です。
まぁ、割と小さいコード (テストを含めても 300 行もない) だし C# を知らない人でもそれなりに雰囲気は掴めると思います。
あ、このエントリかなり長いです。
準備
Windows の場合、Git Bash を開いて、適当なフォルダに移動して
git clone git://github.com/bleis-tift/MotsunabeZombieProject.git cd MotsunabeZombieProject
としてください。
MotsunabeZombieProject というフォルダができて、その中に Git のリポジトリとかができます。
Mac とか Linux とかでも基本そんな感じで、まずは clone してきてください。
git clone は当日説明しなかったけど、すでにあるリポジトリを丸ごと取ってくるコマンドです。
git init が空っぽのリポジトリを作るのに対して、git clone は既存のリポジトリのコピーをローカルに作ります。
clone したら、gitk --all & してみてください。
master ブランチの他に、タグがいくつかあるはずです。
これらのタグは、git now --rebase する前に打ったタグなので、どういうタイミングで git now しているかの参考にしてください。
お題
お題を簡単にまとめると、「つぶやきの種別を判定するシステム」です。
チケットとしては、
- 普通のつぶやきを判定
- ハッシュタグを含むつぶやきを判定
- リプライを含むつぶやきを判定
- メンションを含むつぶやきを判定
- 複数の種別を含むつぶやきを判定
- ネットワークからつぶやきを取得して判定
- 非公式 RT を含む判定
- 現在時刻の前後 30 分のつぶやきを最大 20 件判定
- URL を含むつぶやきを判定
- 短縮 URL の展開
の 10 個で、この中の 1 〜 6 までを実装してあります。
タグに含まれる数字と、このリストの数字が対応しています。
まずは最終形を眺める
とりあえずどんなファイルがあるか見てみましょう。
- MotsunabeZombieProject/
- CategorizedResult.cs
- TweetAnalyzer.cs
- TweetCategorizer.cs
- MotsunabeZombieProject.Tests/
- CategorizedResultTest.cs
- TweetAnalyzerTest.cs
- TweetCategorizerTest.cs
重要なファイルはこの 6 つです。
テストを除くと、クラスとしては 3 ファイルに 4 つのクラスが定義されています。
- TweetCategorizer
- つぶやきの種類を判定するためのクラス
- CategorizedResult
- 判定結果などを保持するクラス
- TweetAnalyzer
- 与えられた URL のつぶやきを判定するためのクラス
- TweetProvider
- つぶやきを TweetAnalyzer に与えるためのクラス
特徴的なのは、CategorizedResult を導入したところでしょうか。
このクラスを導入したため、カテゴリに対するテスト (TweetCategorizerTest.cs) が簡潔かつ分かりやすくなりました。
これは、文字列への変換を CategorizedResult 自体に持たせたことによって、TweetCategorizerTest の方では文字列の一点比較を行う必要がなくなったからです。
TweetProvider というクラス名は Java のペアが使っていたものをそのまま使わせてもらいました。
こいつはテスト用のクラスで、外部から文字列配列を渡したものを TweetAnalyzer に設定するとその文字列配列を使ってくれます。
何も設定しなければ、TweetAnalyzer はデフォルトの TweetProvider を使うため、HTTP 通信を行ってつぶやきを取得します。
この小さなクラスに通信を任せてあるため、他の部分ではネットワークを全く意識する必要がありませんし、テストも完全にオフラインで実行できるようになっています。
テストを除く各ファイルの行数を見てみると、どれも 50 行に収まっています。
メソッド内の行数を数えてみても、今のところ最長で 6 行と、非常にコンパクトに収まっています。
テストコードとプロダクションコードの行数を見てみると、テストコードが約 140 行、プロダクションコードが約 130 行と、だいたい半々になりました。
テストの総数は 23 個で、カバレッジは 87.04 % です。
カバーされていないところを見ると、
- 実際にネットワークに接続しに行く部分 (TweetProvider.GetTweetsFromUrl メソッド) 全て
- その呼び出し部分 (TweetProvider.GetTweets メソッド) の一部
- Debug.Assert で前提条件を埋め込んでいる部分
- 正規表現の一部
となっていました。最後が若干気になりますが、他は問題なさそうです。
実装時間は合計 4 時間くらいで、残っているコミット間の時間を調べてみると、
最長 | 最短 | 平均 |
---|---|---|
約20分 | 0.4分 | 約4.8分 |
となりました。
コミット総数は 67 ですが、残していない git-now のコミットもあるため、実際にはもうちょっと増えます。
普通のつぶやきの判定
gitk は立ち上がっているでしょうか?立ち上がっていない場合、
gitk --all &
として立ち上げておいてください。
さて、ではチケット 1 の実装から見ていきましょう。
昔の状態を取ってくるのには、checkout が使えます。
でも、ここではブランチに馴染んでもらうためにコードを追うためのブランチを作って、ブランチを移動させることでコードを追いましょう。
git checkout -b hoge da1939
これでコードを追うためのブランチ hoge を作成し、そのブランチに切り替わりました。
da1939 というのは、ブランチを作成するコミットのハッシュ値の先頭 6 文字です。
Git では Subversion と違い、リビジョン番号ではなくハッシュ値によってコミットを識別します*1。
gitk に移り、F5 キーを押してください。
左上の領域 (コミットグラフ) を一番下までスクロールすると、先ほど作った hoge ブランチが確認できます。
hoge が太字になっていることからもわかるように、現在の作業ディレクトリの中はさっきまでの最新のものではなく、da1939 のものになっています。
ようやくコードです。
TweetCategorizerTest を見ると、テストケースが一つだけ書かれています。
var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\tほげほげ"); Assert.That(result, Is.EqualTo("Normal\tほげほげ"));
テストコードを書くときの癖なんですが、まず Assert.That(result, Is.EqualTo("Normal\tほげほげ")); までを一気に書き上げてしまいます。
そして、var result = ... と上に上に組み立てていきます*2。
こうすることによって、「結果としてほしいもの」と「どうやってそれを手に入れるべきか」を同時に考える必要がなくなります。
まずは結果として欲しいものを書き下しておいて、それからどうやってそれを手に入れるかの API を考えている、ということです。
そのため、
var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize("bleis\tほげほげ"), Is.EqualTo("Normal\tほげほげ"));
とは動作としては同じなのですが、考え方としては全く別 (こちらは API をまず考えて、その結果を後で考えている) と言えます。
もちろん、テストコードのリファクタリングを行って後者の形にすることもあります*3。
次に実装コードを見てみましょう。
TweetCategorizer ですが、まだ普通のつぶやきにしか対応しないため、本文の先頭に "Normal\t" を追加するだけになっています。
特に説明する部分もありませんので、次に行きましょう。
ハッシュタグを含むつぶやきの判定
以下のコマンドを実行してください。
git reset --hard d6950
このコミットは、さっき見たコミットの次のです。
テストケースを追加し、それを満たす実装を行っています。
特に問題はないでしょう。進みます。
git reset --hard e36fb
コミットを一つ飛ばしました。
このコミットの前に、ハッシュタグの判定のケースを増やそうとしてテストメソッドを書き始めたのですが・・・
[Test] public void ハッシュタグ付きのTweetがHashTagに判定される() { var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\tほげほげ #hash"); Assert.That(result, Is.EqualTo("HashTag\tほげほげ #hash")); } [Test] public void 数字のみの場合はNormalと判定される() { var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\tほげほげ #1234"); Assert.That(result, Is.EqualTo("Normal\tほげほげ #1234")); }
と、ほとんど同じ内容になりそうだったので、このように書かずに TestCase 属性を使うようにテストをリファクタリングしようと思いなおしました。
このコミットはその第一歩で、まず既存のテストと同じものを TestCase 属性を使って書き直しています*4。
git reset --hard 4a15
さっきのコミットの次のコミットですが、TestCase 属性のテストが通ったので、既存のテストを削除しました。
これ以降、git now を使って TDD のサイクルを回して行っています (work/2 のタグを参照)。
この間の各 git now の間隔は、最長約 5 分となっています。
自分の場合は git now の間隔よりも TDD のサイクルの方が短い *5 ので、TDD のサイクルは 4 分とか 3 分とかで回している感じになります。
git reset --hard 772a
チケット 2 がとりあえず終わった状態です。
テストケースがいくつか追加されており、実装もリファクタリングによってわかりやすくなっています。
特にテストケースは TestCase 属性を導入したことによって、無駄に行数を増やさずにテストケースを追加できています。素敵。
リプライを含むつぶやきの判定
git reset --hard 7d57
この時点のテストを抜粋します。
[TestCase("bleis\tほげほげ #hash", "HashTag\tほげほげ #hash")] [TestCase("bleis\tほげほげ #1234", "Normal\tほげほげ #1234")] [TestCase("bleis\tほげほげa#hash", "Normal\tほげほげa#hash")] [TestCase("bleis\tほげほげ #hash", "HashTag\tほげほげ #hash")] [TestCase("bleis\t#hash", "HashTag\t#hash")] [TestCase("bleis\tほげほげ#hash", "HashTag\tほげほげ#hash")] public void ハッシュタグ付きのTweetがHashTagに判定される(string record, string expected) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize(record), Is.EqualTo(expected)); } [TestCase("bleis\t@t_wada ほげほげ", "Reply\t@t_wada ほげほげ")] [TestCase("bleis\t@ ほげほげ", "Normal\t@ ほげほげ")] [TestCase("bleis\t.@t_wada ほげほげ", "Normal\t.@t_wada ほげほげ")] public void リプライ付きのTweetがReplyに判定される(string record, string expected) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize(record), Is.EqualTo(expected)); }
また重複が目につくようになってきましたので、テストコードをリファクタリングすることにしました。
git reset --hard b029
ScreenName は特に使っていませんし、常に固定でも問題ありませんので、ヘルパメソッドを使うようにしました。
string _(string body) { return "bleis\t" + body; }
さらにリファクタリングを続けます。
git reset --hard d6555
さっきのリファクタリングで、テストメソッドの引数は body のみを受け取るようになりました。
ということは、期待する結果はカテゴリと body をタブ文字で連結すればいいことになります。
string _(string category, string body) { return category + "\t" + body; }
さっきのリファクタリングで追加したのと合わせ、2 つのメソッドを導入したことでテストコードはこうなりました。
[TestCase("ほげほげ #hash", "HashTag")] [TestCase("ほげほげ #1234", "Normal")] [TestCase("ほげほげa#hash", "Normal")] [TestCase("ほげほげ #hash", "HashTag")] [TestCase("#hash", "HashTag")] [TestCase("ほげほげ#hash", "HashTag")] public void ハッシュタグ付きのTweetがHashTagに判定される(string body, string expectedCategory) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body))); } [TestCase("@t_wada ほげほげ", "Reply")] [TestCase("@ ほげほげ", "Normal")] [TestCase(".@t_wada ほげほげ", "Normal")] public void リプライ付きのTweetがReplyに判定される(string body, string expectedCategory) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body))); }
重複が消え去った上、どういったテストケースなのかが非常にわかりやすくなりました。
実装コードのリファクタリングを軽く行って、チケット 3 終了です。
メンションを含むつぶやきの判定
git reset --hard 95774
実装をとりあえず終えた段階ですが、今度はテストメソッドの中の重複が気になってきました。
var categorizer = new TweetCategorizer();
Assert.That(categorizer.Categorize(_(body)), Is.EqualTo(_(expectedCategory, body)));
このコードが 3 個所で使われています。ここもリファクタリングしてしまいましょう。
git reset --hard 9b065
ここでさっきまで使っていたヘルパメソッド 2 つを削除し、新たに AssertCategory メソッドを追加しました。
void AssertCategory(string body, string expectedCategory) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize("bleis\t" + body), Is.EqualTo(expectedCategory + "\t" + body)); }
これによって、テストメソッドの中はこうなりました。
AssertCategory(body, expectedCategory);
いい感じです。
更に 3 つのメソッド
- ハッシュタグ付きのTweetがHashTagに判定される
- リプライ付きのTweetがReplyに判定される
- メンション付きのTweetがMentionに判定される
を一つのメソッドにまとめることもできるのですが、これらはテストケースとしては別物だと思っているので、まとめていません*6。
複数の種別を含むつぶやきの判定
git reset --hard 6ff2
とりあえず書いてみたものの、テストメソッドが微妙です。
[TestCase("@t_wada ほげほげ#hash", new[] { "Reply,HashTag", "HashTag,Reply" })] public void 複数の種類を含むTweetがカンマ区切りで連結される(string body, string[] expectedCategories) { var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\t" + body); // 順番はどうでもいい foreach (var exCat in expectedCategories) { if (result.StartsWith(exCat + "\t")) { Assert.Pass(); return; } } Assert.Fail(string.Format("expected starts with [{0}] but [{1}].", string.Join(" or ", expectedCategories), result)); }
パッと見てこれがなんのテストなのか全くわかりません。
これはダメだと思い、判定結果を表すクラスを導入してみることにしました。
git reset --hard 27190
CategorizedResult というクラスを導入しました。
つぶやき本体と判定されたカテゴリを持つだけの単純なクラスです。
git reset --hard 706b
CategorizedResult に ToString を追加しました。
これによって、結果の文字列が等しいことを確認するテストと、判定されたカテゴリが正しいことを確認するテストを分離することができそうです。
git reset --hard f7cc
ここからコードを置き換えていきます。Git があるので、安心して置き換えを試してみることができます。
まずは普通のつぶやきの判定を行うテストを、CategorizedResult を使って書き直してみました。
var categorizer = new TweetCategorizer(); var result = categorizer.Categorize("bleis\tほげほげ"); Assert.That(result.Categories, Is.EqualTo(new[] { "Normal" }));
もちろん、categorizer.Categorize("bleis\tほげほげ") の戻り値の型は string なので、まだコンパイルは通りません。
git reset --hard 43592
Green バーが見たいので、CategorizedResult を返すように Categorize メソッドを書き換えます。
これでコンパイルが通ると思ったんですが、今度は Categorize メソッドの戻り値の型を string として扱っているテストが残っているため、コンパイルが通りませんでした。
git reset --hard 5b5f
面倒なので #if false 〜 #endif で無効化しちゃいました。コンパイルは通りますし、テストも Green です。
あとはテストを書き換えていって、無効化した範囲を狭めていきます。
テストコードのリファクタリングを行って、重複部分をかなり削っていたため、置き換えは非常に簡単に行うことができました。
CategorizedResult 導入のきっかけとなったテストが、CategorizedResult を導入することでどうなったか見てみましょう。
git reset --hard 498a
[TestCase("@t_wada ほげほげ#hash", new[] { "Reply", "HashTag" })] [TestCase("@t_wada ほげほげ#hash", new[] { "HashTag", "Reply" }, Description="順不同")] public void 複数の種類を含むTweetの判定結果に含まれるすべての種類が存在する(string body, string[] expectedCategories) { var categorizer = new TweetCategorizer(); Assert.That(categorizer.Categorize("bleis\t" + body).Categories, Is.EquivalentTo(expectedCategories)); }
非常にすっきりしたテストになりました。
NUnit の EquivalentTo は順番を考慮しない比較を行ってくれます。
置き換え前はひとつのテストケースに結果の組み合わせを人力で記述していましたが、CategorizedResult を導入したことによりその必要がなくなりました。
そのため、テストの追加が非常に簡単にできるようにもなっています。
いくつかテストを追加して、チケット 5 は終了です。
ネットワークからつぶやきを取得して判定
TDD Boot Camp 福岡では、ここで入力形式の変更という仕様変更が入りました。
今までは ScreenName と Body がタブ文字で区切られた形式だったのですが、ここからは yyyy/MM/dd HH:mm:ss 形式の日付が先頭にくっつき、ScreenName との間にタブ文字が置かれます。
つまり、日時と ScreenName と Body がタブ文字で区切られた形式です。
この変更に対応するために、とりあえずテストをひとつ修正してみます。
git reset --hard 4b14
テストを実行してみると、Green です。
・・・あれ?仕様変更の影響を受けていない?と思いつつ、ほかのテストも新形式に修正してみました。
git reset --hard 4efe
落ちました。リプライ判定用の正規表現で、先頭にマッチという条件が含まれていました。
新形式をそのまま既存の Categorize メソッドに渡すと、日付の部分が ScreenName として、ScreenName 以降が Body として認識されてしまうため、Reply を期待しているテストがことごとく落ちました。
git reset --hard ee2e
Body 部分の取得方法を修正し、新形式への対応完了です。
しかし、GetCategory メソッド内部でコレクションをカンマでくっつけて文字列化し、それを呼び出し側で Split するという部分が目についたので、リファクタリングを行うことにしました。
この際、チケットとして分けるのが面倒なので同じチケット内でやってしまうことが多いです。
git reset --hard 04e1
さらに LINQ を使って内部を書き直したところで、チケット 6 本来の作業に入ります。
git reset --hard d4d8
このコミットで TweetAnalyzer を導入していますが、名前の付け方に迷い、20 分くらい使っています。
結局、最終的にはつぶやきの分析を行うことになりそうなストーリーだったので*7、TweetAnalyzer としました。
更に、TweetAnalyzer では Categorize するのが目的ではないという判断から、Categorize メソッドは public ではなく internal にしました。
git reset --hard c0554
ここまでのコミットは、API を決めるために右往左往して、最終的に残ったコミットです。
TweetProvider クラスを導入して、データの取得先をこのクラスに隠ぺいすることにしました。
ここからもあぁでもないこうでもないと試行錯誤しながら、最終的な実装に落ち着きました。
git reset --hard work/6
ここでタイムアップ、新幹線が名古屋に着いたようです。
家で git now --rebase して、ゴミの除去とテストを軽くリファクタリングしたものを GitHub に公開しました。
まとめ
- 最初のテストは Assert First (Assert から書く)
- 結果を決めてから API を設計する (ゴールを見える状態にしておくのは重要)
- 上へ上へ書いていくので、VsVim が便利
- Assert.That 可愛いよ Assert.That
- テストケースのリファクタリング
- ヘルパメソッドの導入
- TestCase 属性
- TestCaseSource 属性
- テストケースを簡潔に書くためにクラスを導入
- git
- git now は TDD との相性がいい
- git now --rebase する前にタグを打つことで、自分の傾向を知ることができる
- git のブランチはとても軽いので気軽に使える*8
*1:なんでハッシュ値なんてわけわからないものを使ってるの?という疑問を持った方は、入門 Git (大文字の方) か実用 Git をどうぞ。[http://d.hatena.ne.jp/bleis-tift/20100922/1285140344#_commitid:title=リビジョン番号がない話]あたりもどうぞ
*2:VsVim を使っている場合、
*3:なので、テスト対象のメソッド呼び出しの後からメソッドチェインでつなげていくのは TDD とは相性が悪いです。もちろん、既存のコードに対してテストを追加する場合はそういうテスティングフレームワークは強力でしょうが、NUnit の Assert は TDD と相性がいいのです。一部 C#er からは Assert.That ないわー、とか言われちゃってますけど、俺はアリ派です。Assert.That 万歳!
*4:この際 result 変数を除去していますが、ある程度 API が固まったと感じたために assert first を一旦やめました。API が固まっているのに assert first するのにあまりメリットを感じないからです。この辺りは人によって考え方が違いそう。
*5:VS 上で git の操作ができないというのが git now の間隔と TDD のサイクルが一致しない主な原因だと思います。テストを実行する手間は TestDriven.NET によってほとんどないですが、git のコマンドを実行するためにはウィンドウを切り替える必要がある
*6:ただ、メンションのテストとリプライのテストに完全に同じものがあるので、これは消した方がいいのかも。
*7:後で何らかの形でお題が公開されると思いますので、参加者以外の方はそれまでよくわからないかもしれませんが、そんな感じだったのです。
*8:今回ブランチを使って過去のコードを取り出していましたが、どうでしょう?全然遅くなかったのではないでしょうか?