こんにちは、Baseconnectエンジニアの御園です。 最強寒波の時「気温がunsigned intになってくれない」とか「冷たいからrangeでチンする」とか色々と思いついたのですが、どこに吐き出すでもなく一人ほくそ笑んでいたおもしろ系エンジニアです。 別にそんな事はどうでもよく、そもそもTDDとは何か?について、そしてやってみた感想を記事にしてみたいと思います。
introduction
例えば貴方が学校に通っているとして、今にも遅刻しそうだとします。 すると当然のことながら大半の人は走って間に合おうとするでしょう。(少数の人は諦めて開き直り、歩くかもしれません。そのような人々はExceptionです。)
その時、食パンを加えた美少女が角から飛び出してきたら貴方はなすすべもなく衝突してしまうでしょう。 そして美少女に文句をつけられ、結局遅刻し、散々な目に合うかもしれません。
その後、学級ではどこからともなくリークされた転校生の話題でfullですが、貴方は脳RAMをそんなものに割く余裕もなく、今朝の事でムカついてしまい、うまくGCする余裕もありません。
やがて訪れた朝の学級会で、担任の先生が転校生を紹介した時、その転校生は今朝貴方とシノニムを起こしたばかりの美少女で、お互いに指を指し合って「あの時の!!!」となり、そこから青春のストーリーがプロセススタート……
おっと、ブラウザバックするのはまだ早いです。 OK、ゆっくり、まずはコーヒーでも一杯飲みながら、続きを読んでみて見ください。 読者の”あなた”が読んでいるのは、「TDDをやってみた感想」という題名のブログ記事で間違いありません。
このイントロダクションのような出会いを、実際に目にしたこと、あるいは経験したことがあるでしょうか? もしそのような事がexistsな方々は、幸運としか言いようがありません。
残念ながら私はありません。というか普通の人はないと信じたいです。たいてい、ドラマか、アニメの中の話でしょう(最も、最近はそんな王道展開の物語も少ないかもしれませんが)。
筆者、御園とTDDとの出会いは、まさに上記のような感じでした。TDDという言葉はやんわりと聞いたことがあったのです。日本語訳が「テスト駆動開発」というのもなんとなく知っていた程度ですが、本当にその程度の知識です。
しかし実際にTDDに触れ、TDDを知り、そして実践していく中で、その出会いは確実にまさに衝撃と呼べるものでした。少し誇張していて興奮気味ですし、読者の中には「おいおいそれは言いすぎだぜ」と思う人もいるかも知れません。
これからの文章は、あくまで筆者の感想として捉えていただき、しかしながら生暖かい目で見守っていただき、それでいてTDDへの理解が少しでも深まれば幸いと思って書くことにしますので、アイスブレイク的にゆっくりと楽しんでいただければと思います。
TDDの導入
冒頭のストーリーを思い出しましょう。 今度は貴方はそのストーリーの主人公ではなく、ストーリーの実装者です。 簡単な、ゲームのようなものを作ると考えてみてください。 ストーリーを完成させるには、まず主人公は学校に遅刻しており、そして間に合わせるために走らなければなりません。 擬似コードを書いてみます。
main_character.set_state('遅刻') main_character.run
なんとなくRubyチックに書いてみましたが、あくまで擬似コードなのでそこまで気にしなくて良いです。 さて、ここでストーリーを完成させるための冒頭を開始したわけですが、実際には上記のコードが上手く動いているかを確かめる必要があります。 つまり”テスト”を書くということです。 簡単に書くならばこんな感じでしょうか。
expect(main_character.get_state()).to eq('遅刻') expect(main_character).to receive(:run)
主人公の状態が遅刻になっているかと、”走る”が呼び出されているかを確認しました。
さて、通常の開発フローであれば何も違和感がないように思えます。 ですが、今回の主題はTDDでした。
Test-Driven Development
つまり、テスト駆動開発は、その名の通りテストを主軸に置いた開発を行います。 TDDにおいて、開発の一番最初に行うのは、テストを書くことであり、もっと言えばそのテストが落ちることを確認します。 先程の例を基にしてみましょう。
expect(main_character.get_state()).to eq('遅刻') expect(main_character).to receive(:run)
TDDにおいては、一番最初に書くべきコードはこれであり、そしてこのテストが落ちることを確認します。 実装が何もなければこのテストが落ちることは自明です。 テストが落ちることを確認したなら、次にやるべきことは「このテストを通すこと」です。 ここで面白いことが起きます。このあと、同じように
main_character.set_state('遅刻') main_character.run
と実装を進めていくわけですが、その目的が「テストを通すこと」にTDDでは成り代わっていることに気づいたでしょうか?
もちろん「遅刻していて、走り出す」という実装をすることには間違いないのですが、TDDではない最初のやり方だと、
・「遅刻している」「走る」を実装する
・それらの実装があっているかを確認する
の順序になっていたのに対し、
TDDでは
・「遅刻している」状態をテストする。落ちることを確認する。
・「走る」が呼ばれているかテストする。落ちることを確認する。
・テストが通るようにする。(「遅刻している状態にする」を実装する、「走る」を実装する)
と、最終的な実装は「テストが通るようにする」ところからスタートするのです。 ここにTDDの妙が詰まっています。
保証されたリファクタリング
TDDでは、「まずテストを通すこと」を一番大事にします。そのため、”綺麗にコードを書く”事は一旦意識しなくて良いのです。 例を見てみましょう。例えば、先程のテストを持ってきてみます。主人公が遅刻しているということを確認するテストです。
main_character = MainCharacter.new expect(main_character.get_state()).to eq('遅刻')
このテストを通すために、最低限どのようなことをすればよいでしょうか?
シンプルに考えることが大事です。 それはつまり、
・MainCharacterクラスに遅刻
を返す get_state
メソッドがあれば良い
という事に他ならず
class MainCharacter def get_state return '遅刻' end end
という実装をすればよいのです。ちょっと待って……。貴方が言いたいことはわかります。 普通の精神をしてたらこんなコードは書かないです。
でも、TDDではある意味”これが正解”なのです。 なぜなら、「テストが通るようにする」という目的はこの実装で達成されるのですから。
では、何が気に入らないのでしょう?
・get_stateで遅刻しか返らない。本来なら他のいろいろな状態を保持するようにしたいはず。
当然の疑問です。それは正解です。ではそのように少しコードを変えてみましょう。
class MainCharacter def initialize @state = '普通' end def set_state(state) return @state = state end def get_state return @state end end
MainCharacter
クラスがインスタンス変数 @state
を持ち、これによって様々な状態を保持するようになりました。セットするための set_state
関数も実装されています。
さて、先程のテストは通るでしょうか?
main_character = MainCharacter.new expect(main_character.get_state()).to eq('遅刻')
当然、落ちます。
なぜなら主人公を遅刻させるための set_state
が呼ばれていません。
このテストに set_state
を使う必要があるようです。
main_character = MainCharacter.new main_character.set_state('遅刻') expect(main_character.get_state()).to eq('遅刻')
テストが再び通るようになりました。
ここで示したのは極端な例ですが、TDDを行う時は「まずテストを通るようにする」という事が大事です。 そしてその先にリファクタリングがあるのです。
そしてそのリファクタリングも、最終的には「テストを通すようにする」に帰結しています。 テストが通ることは、コードの変更前と変更後で、期待する結果が得られていることの保証になり、心理的に開発者を安心させることになるのです。
- テストを書く
- テストが落ちることを確認する
- ”取り敢えず”テストを通るようにする
- 3.で生まれたリファクタリングポイントを解消していく
- テストがすべて通るようにしておく
これが私が実際に体験したTDDのポイントです。リファクタリングはあとなので、例えばテストのファイル単一に全ての実装コードを書くこともあります。 本来なら実装段階でいくつかのファイルに分けて綺麗に書きたいところですが、TDDでは単一のファイルに書いてから、後で外側のファイルに吐き出す。という事をしました。
動作はテストが保証してくれます。 リファクタリングしたいな、と思うメモの項目一つ一つを解決していく際に、テストが通るという保証が付き添ってくれるので、安心しながらリファクタリングをすすめることが出来るのです。
そして、実装を変更したいときも、テストがついていれば「テストを通るように」すればよく、常に私達は目的を見失う事なくコーディングが出来るというわけです。
放課後(ただの感想)
TDDを体験した時、私が一番の衝撃を受けたのが、”リファクタリングが後”という事でした。 (殆どの人がそうだと私は信じていますが)私はコードを書く時に「いかに綺麗に、保守しやすく、他の人が読みやすいコード」を意識しています。
だからクラスは別ファイルに階層を意識して書くし、テストも通常別ファイルです。 ですが、私のチームで実践したTDDでは、テストのファイルに実装も一気に書いてしまい、テストが完全に通るようになってから、ちゃんとしたファイルに書き出す(私にTDDを教えてくれたメンバーはこれを"extract"と表現していました)事をしています。
なんて原始的で、でも着実なんだろう。と思いました。
プログラミングを学びたての頃は、単一のファイルに数百行以上のコードを書くなんてことはありましたが、クラスやモジュール、名前空間などのファイル毎に分けるようなテクニックを学んでからは、ほとんど単一のファイルに大量のコードを書くことなんてありませんでした。
ですが、TDDではそれがありえてしまうのです。
一旦全て実装を書ききってしまい、テストが動くという保証を基に、適切な位置にコードを配置していく。 バグはテストが動かないということそのもので見つかる、なんて素敵なんでしょう。
ここまで感想を述べると、まるで私がTDDの事を完璧に思ってるように見えます。 ですが、不満もあります。いくつか見ていきましょう。
- 開発時間は多くなる
先程示したとおり、非常に小さなステップもテストとともに進んでいくので、開発時間はどんどん多くなっていきます。
完璧なプログラマなら最初からリファクタリング済みの完璧なコードをかけるような時間に対して、TDDは”着実に”進んでいくので、どうしてもコードを書く時間は伸びるのです。
- 正しいテストかどうか、テストを書く人(見る人)に委ねられる
それが求めている仕様を表すのに本当に正しいテストかどうかは、よく吟味しなければなりません。 やもすれば、全然意図しない間違ったテストを基に実装してしまうことは、開発そのものを破綻させてしまいます。
本当に正しいテストかどうか、テストの質はどうか、そもそもテストを設計しにくい仕様だと長いテストを書いてしまわないか…… テストが正しいということを前提にした開発は、テストがそもそもちゃんとしていなければならないのです。
それはどうやって保証していけばいいのでしょうか。
私のチームではあまりそういった事はなかったのですが、例えば仕様変更などによるテストのメンテナンスコストなども、話には聞きます。 仕様変更に追従してテストを大幅に、やもすれば1から書き直さなくてはいけない。 後からテストを書くケースに対して、この場合はテストも実装もまるごと変わっていくので、大変なのは想像に難くないでしょう。
よければ、TDDを教えてくれたメンバーの記事もご覧下さい! techblog.baseconnect.in
Outro
さて、いかがだったでしょうか? TDDとはどういうものなのか、そしてそれに関して私が感じたことを少しでも共有できたなら幸いです。 一つ言えることは、TDDを導入したからといって、遅刻した時に曲がり角で美少女と運命的なシノニムを起こすということはないということです。ですが、それを補って余りあるTDDの恩恵があることは確かなので、試験的に導入してみてもい良いのではないでしょうか?
Baseconnectでは美少女と出会う時のように、新しい技術に出会った時に運命的なドキドキを感じるエンジニアを募集しています。 興味が湧いた方は、テストケースを握りしめて以下の採用ページを覗いてみましょう。
https://herp.careers/v1/baseconnect/requisition-groups/9607685a-4d6c-4b90-9f44-ac654b11d986