TDD Boot Camp 北陸に参加してきた!
3 月 13 日、14 日に行われた TDD Boot Camp 北陸に参加してきました。
12 月 19 日に東京で開催された第一回にはとある理由で参加できなかったので、「次こそは是非!」と思い、参加受付開始直後、というかアナウンス前に参加登録をしたくらい気合いを入れてました。
で、感想ですが、いや、素晴らしいイベントでした。企画してくれた id:katzchang (かっちゃん) さん、本当にどうもありがとう!
書いたコードはそのうち公開するかも・・・
以下 2 日分の感想なので長いです。注意。
オープニング
id:t-wada (和田さん) による入門講演で、TDD の勘所を押さえつつ、分かりやすい発表でした。
まだ読んだことのない本もあったので、余裕ができたら買います。
その後、id:t-wada と id:katzchang によるペアプロの実演があり、昼休みをはさんでペアプロによる TDD が開始しました。
お題はワードフィルターで、登録した単語を検出し、別の単語に置き換える、という単純なものです。
再帰 CTE にはまったきっかけ
最初にペアを組んだ方に「なぜ再帰 CTE をやろうと思ったの?」的な質問をされ、その場ではきっかけを思い出せなかったんですが、思い出しました。
カスタマイズ方法が SQL くらいしか用意されていないとある業務用のアプリケーションで、「文字列を連結して欲しい」という要望がありまして、それを実現するために使ったのが最初ですね。
Parameterized Test へのリファクタリング
今回は結局 VisualStudio 2010 (C#) と NUnit2.5.3 を使うことにしたので、重複したテストはどんどん Parameterized Test にリファクタリングしていきました。
NUnit での Parameterized Test は
TestCase 属性などによるテストコードのリファクタリング - ぐるぐる〜
で説明してますが、手順を説明してないことに気付きましたw
注意点は、リファクタリング対象のテストも残しておき、Parameterized Test を新規に作成して両方が Green になることを確認してから対象のテストを削除する、という点です。
テストが設計を駆動する感覚
時間をそれなりに余らせて最初の要求を満たすコードができたので、複数のフィルターに対応するように機能を拡張しようか、ということになりました。
この時点で、NG ワードを検出する Detect メソッドと、NG ワードを置き換える Censor メソッドを持つ WordFilter クラス、更にそれに対応するテストコードがありました。
WordFilter クラスは
var filter = new WordFilter("NGワード"); Assert.That(filter.Censor("bleis: NGワード"), Is.EqualTo("bleis: <censored>"));
のように使う単純なクラスです。
複数ワードに対応させる際に次のようなテストコードを追加した段階で、少し引っかかりを覚えました。
var filter = new WordFilter("NGワード1", "NGワード2"); Assert.That(filter.Censor("bleis: NGワード1"), Is.EqualTo("bleis: <censored>")); Assert.That(filter.Censor("bleis: NGワード1NGワード2"), Is.EqualTo("bleis: <censored><censored>"));
現在、WordFilter には 2 つのメソッドがすでに実装されている状態ですが、この設計だとどうしても既存のメソッドの内部を変更しなければなりません。
すでにテストがあるので安心して作業を進めることはできますが、2 つのメソッドを同時に変更するのはなんだか腑に落ちません。
そこで設計を見直し、WordFilter クラスはそのままにして WordFilter をまとめる FilterList クラスを導入することにしました。
この判断により、WordFilter クラスもそのテストも変更することなく、追加機能を実現できました。
また、テストが設計を駆動するという感覚も実感できました。テストが書きにくい場合や、何かしら引っかかりを覚える場合、それは設計を見直すきっかけにすることができる、ということでしょう。
この感覚を得ることができたのは、TDD Boot Camp 北陸に参加することで得ることができた大きな成果です。
TDD しながらドキュメンテーションコメントを書く
今回このイベントに参加するに当たって、
TDD と Git についてメモ - ぐるぐる〜
で思いついた TDD しながらドキュメンテーションコメントを書くという方法も試してみよう、と目論んでいました。
実際にやってみたのですが、それなりに手応えを掴むことができました。
ただし、確かにドキュメンテーションコメントは書きやすいのですが、設計を見直す、という効果は期待したほどではなかったです。
これはおそらく、設計を見直そうとすると、TDD のリズムが崩れてしまうからでしょう。
設計の見直しは、TDD のレッド、グリーン、リファクタリングのサイクルの中で行うのがやはりいいようです。
Git とレビューの相性
最初のペアプロが終了し、レビューの時間となりました。
他のペアを見て、「俺、発想力ないなぁ」、と・・・あまりに異常系のテストがなさ過ぎです。
自分たちのペアは NUnit で Parameterized Test を行っていたということで発表しましたが、その際感じたのは、Git とレビューの相性が良いことです。
Git では気軽にブランチやタグが作れる上、簡単かつ高速に昔の状態に戻せるため、完成系のソースだけでなく、最初の状態から説明することができました。
発表時は master ブランチを git reset --hard で色々な場所に移動させていましたが、よく考えたらレビュー用にブランチ切るのがいいですね。よく考えなくてもレビュー用のブランチ切るのがいいですね。
JUnit での Parameterized Test は・・・ちょっと重くてあまり使う気になれないかも・・・
その後休憩をはさんで、ペアの変更を行って 2 回目のペアプロに突入しました。
Tweet オブジェクトの導入
仕様変更の中に、「発言者名を置き換えてしまうのはマズイので発言者名は対象から除外してほしい」というのがありました。
1 回目のペアプロでこの点に気付いたペアもいましたが、自分たちのペアは全く気付いていませんでした・・・
で、どうしよう?となったのですが、新しくなった自分たちのペアでは、Tweet クラスを導入することにしました。
このクラスは「発言者名:発言」という書式の文字列を受け取り、それを発言者と発言にわけて保持するだけのものです。
最終的にはこのクラスには ToString メソッド、 Equals メソッド、そして発言から NG ワードを置き換えるメソッドを持つクラスになりました。
最初の実装では単純に Split(char) メソッドを使用していましたが、「:」を含む発言を考慮し、Split(char[], int) メソッドを使うように変更しました。
ペアによってはこの部分を独自に実装しているペアもあったので、このメソッドは案外知られてないのかもしれません。
他のペアには発言者に「:」が含まれることを考慮しようとしていたペアもいましたが、発言者に「:」を許してしまうとどこまでが発言者なのかの判断が不可能になってしまうため、そこは考慮する必要はないと判断しました。
Explicit 属性
自分たち以外の C# 組は、テストを一時的に無効化するためにコメントアウトを多用していた用ですが、NUnit には JUnit 同様 Ignore がありますので、そちらを使った方が良いでしょう。
今回、自分たちのペアは既存のテストを壊すような作業をしなかったので、Ignore 属性の出番はありませんでした。
しかし、新しく作ったテストがしばらくレッドになることが判明した時点で、Explicit 属性を使って一時的に無効化しました。
Explicit 属性は Ignore 属性と違い、Explicit 属性の付いたテストのみを明示的に選んで実行するとテストが走りますが、それ以外の方法ではスキップされます。
この属性は TDD との相性がなかなか良いと言うことを今回実感しました。
ファイルからの読み込み
仕様変更の中に、「ファイルから読み込んですべての NG ワードを置き換えて欲しい」というのがありました。
Java なら Mock を使って一発で終わりなのですが、C# は Stream や Writer がかなり浅い型階層になっている上、デフォルトが virtual ではないので Mock との相性は最悪です。
最初に思いついたのが、独自に型階層を作り、Mock を差し込む余地を作る、というものだったのですが、もう残り時間もわずかでした。実装している時間がありません。
なのでこの方法は断念し、ファイルの内容はすでに string の配列に格納されているものとしました。
C# でファイルの内容を含む string の配列を得るのは簡単 (File.ReadAllLines(string) メソッドを使うとか) なので、テストとしてはその前提を置いても問題ないはずです。
で、本当に時間がなかったので、LINQ を使って「明白な実装」を行いました。
public string[] CensorAll(string[] lines) { return lines.Select(t => new Tweet(t).ReplaceNGWord(ngWord, okWord).ToString()).ToArray(); }
うわ明白!・・・ごめんなさいw
帰りの電車の中で、更に簡潔にリファクタリングされました。
public string[] CensorAll(string[] lines) { return lines.Select(Censor).ToArray(); }
実は string Censor(string) というメソッドが同じクラス内に存在したため、それを使えば何も問題ないわけですからね。
最終的には、
public IEnumerable<string> CensorAll(IEnumerable<string> lines) { return lines.Select(Censor); }
まで単純化かつ抽象化されました。
で、ここまで実装してタイムアップ。
結局、5 つの仕様変更のうち 4 つが実装できた・・・と思ったのですが、実は 1 つ完全ではなかったというオチが付きましたw
1 つの仕様変更に 2 つの項目があって、そのうち片方のことをすっかり忘れてしまっていたという・・・
一瞬で対応可能なので許してください><。
デフォルト値を持った引数
他の C# ペアのレビューで出てきたデフォルト値を持った引数ですが、存在をすっかり忘れてました・・・
自分たちのペアではコンストラクタを 2 つ用意し、片方のコンストラクタがもう片方のコンストラクタに固定値を渡す、という実装にしていました。
そのとき、横着して既存の引数が 1 個のコンストラクタを直接編集し、引数を 2 個にする、という方法を取ってしまったというのも反省点です。
引数にデフォルト値を持たせる方法を使っていればこんなヘマはしなかったはずです・・・
結論:俺は C# 4.0 をもっと勉強するべき!
Tuple
他の C# 組が Tuple を使っていたのですが、Tuple を生成するために Tuple.Create(...) なんて長ったらしく書かないといけません。
3.5 時代に作った独自の Tuple は、Tuple.Of(...) で済むようにしていたので、標準に入ったとはいえそれに切り替えたいと思えないのですが・・・
とりあえず、Tpl というクラスを作ってそこに Of メソッドを置くことにしました。Of メソッドはもちろん内部で Tuple.Create メソッドを呼び出すだけです。
C# の生産性の高さ
C# 組は一部偽装ペアがいますが、どのペアもかなりの仕様変更に対応できていました。
なぜそうなったのかを考えると、
- C# では Parameterized Test が簡単に書けるため、テストを追加 (または削除) するための心のハードルが低い
- LINQ と IDE の組み合わせが強力で、簡単かつ自然に関数型言語のパラダイムを取り込める
と言うのが大きいのかな、と思います。
ここで注意すべき点があるのですが、IDE (つまり VisualStudio) が強力なわけではなく、LINQ との組み合わせが強力だ、と言っている点です。
この違いは重要で、IDE だけを見ると VisualStudio は Eclipse や NetBeans の足下にも及びません。Express Edition で Add-In が使えない時点でダメダメです。
しかし、LINQ の力を引き出すためにはそう多くの機能は必要ありません。「.」でつないだときに次の候補を出してくれればそれでいいのです。
関数型言語のパラダイムを「.」でつないだスタイルで流れるように実装できる、これが C# の生産性の高さに一役買っているのだと思います。
文字列操作が便利、というのをあげた方もいますが、初期状態では正直 C 言語よりはマシと言う程度で、Python や Ruby には敵いませんし、Commons Lang の StringUtils があるため Java にも敵いません。
あと、文字列操作は便利ですが、せっかく静的型付けなのに、文字列操作を多用してしまうとそのメリットがそがれます。
なので、個人的には文字列操作大好きですし、Commons Lang の StringUtils は超便利だと思いますけど、文字列処理を多用するのはあまり好きではない、というのもあります。
実装後に気付いたこと
実装中は全く気付かなかったのですが、出来上がったコードのメソッドあたりの行数がすごいことになっていました。
なんと、メソッドの最大行数は Tweet クラスのコンストラクタの 3 行で、その他のメソッドは 2 行もしくは 1 行となっていたのです。
プロダクションコード全体としては、ドキュメンテーションコメントも含まれている状態で 130 行程度でした。
繰り返し文がプロダクションコードにもテストコードにも存在しなかったのも印象的でした。
ちなみに、一番長かった Tweet クラスのコンストラクタは
public Tweet(string tweet) { var splited = tweet.Split(new[] {':'}, 2); User = splited[0]; Message = splited[1]; }
です。
まぁ、異常系をほとんど考えていなかった、というのも行数が短くなった原因ではありますが、もし異常系を考えたとしても同じくらいの水準を保てる自信があります。
TDD により、クラスの粒度が適切に保たれる、という感覚が今の自分にはあります。
TDD で開発することで、単一責任原則が自然に守られる、という感覚です。
夜
なんだか夜にも色々あったようですが、前の一週間色々と忙しくて疲れていたので、さっさと寝てしまいました。
ちょっと残念。
以下 2 日目
バージョン管理と課題管理、というかソフトウェア構成管理
2 日目が始まる前、同室になった方とソフトウェア構成管理について色々と話をしました。
やっぱり色々と難しいよなぁ、という・・・
ローカルでのバージョン管理
2 日目は id:katzchang のレガシーコードに対して id:t-wada と id:katzchang がテストを追加していく、という実演から始まりました。
使用されたレガシーコードは、Ioke という言語のプログラムを解釈する Twitter 上のボットです。
いの一番にコードを Git のリポジトリに登録することからはじめていましたが、ローカルなのに分散型のバージョン管理システムを使う意味はあるのか?という疑問を持つ方もいるかもしれないので、少し補足しておきます。
ローカルで集中管理型のバージョン管理システム、ここでは Subversion よりも、分散型のバージョン管理システム、ここでは Git を使う意味として、
の 2 点を紹介します。
SVN よりも Git の方が動作が高速
まず、SVN では自分の環境だけのブランチ、という考え方がありません。また、ブランチを作ること自体、分かりにくいしタイプ数も大変です。ブランチは単なるコピーなので、ブランチを切り替えるにはすべてのファイルをリポジトリから作業ディレクトリに転送する必要があります。
それに対して Git では、自分の環境だけのブランチがデフォルトです。ブランチを作るコマンドも分かりやすく、タイプ数も少ないです。ブランチは Git のオブジェクトを指すポインタで、Git のオブジェクトはイミュータブルになっており、内部で共有可能なので、ブランチを切り替えるには変更のあったものだけを転送します。
また、Git はあるオブジェクトとあるオブジェクトが等しいかどうかをハッシュ値によって算出するため、オブジェクトの等価性を高速に調べることができます。
SVN よりも Git の方がより柔軟
SVN は基本的にリポジトリは 1 つしかないので、一度コミットしてしまったものを書き換えるという発想がそもそもありません。
それに対して Git はリポジトリが複数あることが考慮されているため、公開していないリポジトリの歴史はどのようにでも弄ることができます。
よくある「さっきしたコミットに含めなきゃいけないファイル含め忘れた!」というのも、Git を使っていれば簡単に解決可能です。
また、SVN よりも Git の方が高速、というのもあるのですが、ストレスや引っかかりを感じることなくブランチを多用することができます。
ローカルでソースコードをバージョン管理したい場合は、SVN よりも Git を選択した方が本来の作業を邪魔せず、むしろ作業の助けになってくれるでしょう。
また、Git でなくても多くの分散型バージョン管理システムで同じ事が言えるはずです。
Subversion の操作体系に慣れているなら、おそらく Mercurial や Bazaar の方が Git よりも簡単に使えるようになるでしょう。
インスタンス化が重い場合
Ioke の実行環境クラスをインスタンス化するのにかなりの時間がかかっていました。
そこで、「インスタンス化が重い場合、1 テスト 1 アサートを外れることも考慮に入れる」、というのはなるほどな、と思いました。
イミュータブルなオブジェクトなら static フィールドとして保持しておいて使い回す、という方法でも大丈夫そうです。
ミュータブルでも、状態をリセットするようなメソッドが用意されていて、そのメソッド呼び出しにそれほど時間がかからないなら、各テスト終了後に状態をリセットするというのもありかもしれません。
実演の大まかな流れ
大まかな流れです。特にメモを取っていたわけではないので、本当に正しいかどうか自信はありません(ぇ
- id:katzchang がどういうプログラムかをざっと説明
- プログラムの今の状態を調査
- 仕様化テスト
- 機能追加中で動かないらしい、ということが判明
- インスタンス化可能かどうか調査
- 非 static なインナークラスを static なインナークラスに変更
- 何も考えずに static を付ける
- コンパイルエラーを直す (コンパイラまかせ)
- 動かない
結局 Twitter のライブラリが古くなっていて動かないらしい、ということが判明して昼休みとなりました。
昼休み後、Twitter のライブラリを最新にしたら動いたそうです。
トリオプログラミング
午後は各言語毎に分かれて、レガシーコードにテストを追加し、機能の追加などを行いました。
C# のライブラリが題材になったのですが、ライブラリは機能が独立しているのでテスト追加するのは簡単だし、機能追加も何も気にせずにできたのでなんというか本来の趣旨からはかなり外れたものになりました。
追加した機能は、Commons Lang の StringUtils#substringBefore、StringUtils#substringAfter、そして Scala の stripMargin です。
stripMargin は時間がなかったので、LINQ でさくっと実装してしまいました。
ライブラリ開発と TDD
最後にどんなことをやったのか、というのを各言語毎に発表しました。
C# 組の発表でライブラリ開発では TDD のメリットはあまり感じないと言いましたけど、それでもなお TDD しないよりも良い、と言うのを言いそびれました。
TDD で実装を進めたわけですが、その途中、何度もテストに助けられました。
使う変数を間違えたり、ライブラリのメソッドに持っていた間違った認識などをテストがことごとく検出してくれる、常にフィードバックをもたらしてくれるため、バグは確実に減るはずです。
ただし、「TDD をしている感」はかなり薄かったです。id:t-wada 曰く、「仕様が固まっているからでは?」とのことでしたが、まさにその通りだと思います。