先日、和田さんが【翻訳】テスト駆動開発の定義という投稿をされていました。原文はKent Beck氏によるCanon TDD であり、テスト駆動開発(TDD・Test Driven Development)の定義とよくある勘違いについてまとめてある記事です。
反応を見るに、以外にTDDの定義を知らない人が多く少し驚きもある気がします。Railsからプログラミングに入った人間なのでたまたま定義を知っていただけだと思います[1]。
私は基本的にテストファーストを採用していますが、テスト駆動開発は利用していません。なぜ私はテストファーストで開発するのか、そしてなぜ私はテスト駆動開発しないのかを考えてみます。
こちらの記事は私の読みやすいテストコードのために心がけること ver 2024.の要素を多分に含みますが、こちらは手法に多くを割いており、この記事はより概念的な話です。
私の開発手法
私はテスト駆動開発以外のテストファーストを行っています。近いのはBDD(Behavior・Drive・Development・振る舞い駆動開発)ですが、異なります。
BDDは名前の通り振る舞いに着目したものでありTDDから派生したと言われています。TDDから派生したからなのか名前と定義に乖離が生じているような気もします。
BDDの確実な定義は申し訳なくも知らないのですが、下記の要素を含んでいると思われます。
- Given(どういったとき)-When(何をやったら)-Then(どうなるのか)という振る舞いをリストアップする
- 自然言語で記載する
- 開発者だけではない人間を巻き込む
私が行っているのはGiven(どういったとき)-When(何をやったら)-Then(どうなるのか)という振る舞いをリストアップする部分のみであり、他は行ってないです。ただ、考えてみるとこれはすべてのテストに共通する考え方です。これはAAAに近い考え方です。AAAはArrange(準備)・Act(実行)・Assert(確認)、またはAssemble(前提条件を組み立てる)・Activate(対象を起動する)-Assert(確認)の頭文字であり(一般的には前者が利用される)、単体テストの構造のパターンです。単体テストと書いてはいますが、基本的に自動テストはこの構造になると思います。
テスト駆動開発でもテストのリストアップは行います。上記記事でいうと「ステップ1. テストリスト」の部分ですね。テスト駆動開発と大きく異なる点はすべてのテストケースを実装します。
すべてのテストケースの実装
一般的にすべてのテストケースを実装するのは「誤ったTDD」ですし「アンチパターン」なんて呼ばれたりもするでしょう(後者は見たことないので、想像です)。
すべてのテストケースといっても、範囲は「今から実装する関数やクラス」だけです。
具体的に、X(Twitter)のような短文投稿サービスで新しい機能を開発していて送信された時にメンション先が指定していた場合メンション先に通知する機能を新たに開発するとします。
この時の実装は(もしなければ)「新たに通知する機能の追加」と「送信機能の修正」となります。
私が実装する場合は、まず「新たに通知する機能の追加」のみ に着目し実装します。開発段階では、送信機能に強く影響する機能ではありますが、強く依存してしまう、つまり密結合な実装にならないための措置です。
最初に行うのはインターフェース決めです。
テストを最初に書かないのでテストファーストじゃねぇじゃんという気もしますが、TypeScriptを主戦場としているため、インターフェースを書かないとテストしづらいためそうしています。
また、一般的に実装とインターフェースは分離しているべきです。この段階でインターフェースを考えておくと実装とも、なんならテストとも分離したかなりクリーンなインターフェースを考えることができます。
また、見た目ではいきなりインターフェースを決めていますが、実際には暗黙のうちに設計しています。どういった要件なのかを理解し、設計を考え、仕様を考える。実装中に気づくときも多いですが、どうしても要件を満たせない・仕様が複雑になりそうなど気づく場合は適宜事前にコミュニケーションを行います。簡易的な設計したあとにようやくインターフェースを書き、そしてテストを書きじめます。テストよりもまずは設計です。これはTDDも同じですね。
続いて行うのが、テストケースの羅列です。これはTDDと同じです。
設計すると先ほど書きましたが、この時に仕様を具体的に洗い出します。この仕様と結びつくのがテストケースであり、テストケース = 仕様となります。私にとって具体的な仕様のメモ帳がテストケースとなります。つまりテストケースを書きながら具体的な仕様を考え、仕様を考えついたらまずテストケースに記載します。
テストより先に設計と書きましたが、やり方が若干矛盾しています。
私の中の整理として、最初は要件からざっくりと設計します。要件を満たすためにはどうすればいいのかを考えて、複数クラス、複数メソッド、複数関数に分離し・それぞれがどういった機能を持ち・どういった関係を持ち・既存の処理のどこを修正するかを考え・既存の処理の影響範囲を考えるといったフェーズです。
その後各機能(大抵は「publicな」関数・メソッド単位です)についてインターフェースを考え、そこで初めて具体的な仕様を考えるわけです。
書いていて思いましたが、あくまでインターフェースというのは、複雑性渦巻くコードの中でお互いがどう関係しているかということであるため、インターフェース設計 => インターフェース実装 => 仕様設計 => 仕様実装という順番で考えている、ということだと思います。
続いて行うのがすべてのテストケースの実装です。TDDとの最大の違いですね。どれぐらい実装をちゃんと行うかというと、問題なければそのままPRを作って投げるレベル、つまり適当に作るとかではなくガチガチに作ります。
ただ後ほど記載しますが、ここでは完全なテストを書くことは一切期待していません。規模によりますが、大抵の場合はテストコードが原因でテストが落ちます。
そして普通にすべて実装し、テストを実行し、テストが通るように実装ないしテストコードを修正します。また、実装していて気になった部分はテストケースに追加したり、最悪仕様を変えたりします。
なぜ私がこんな開発手法を採用しているのか
元々私は単体テストは好きではありませんでした。実装を見れば振る舞いなんて分かるので。
しかし長い事開発していると(と言ってもまともに開発し始めたのは3年ですが)、テストは振る舞いを確認するだけのものではないことが思うようになりました。
例えばまだ見たことない機能を調べる時に、もちろん実装も多く見ますが、テストケースを読むだけでどんな実装があるのか分かります。
また実装に依存したテストは危険であるとも思うようになりました。一般論としても言われることがありますが、通すためのテストを書くようになってしまい、結果的にテストの品質が悪くなり最悪の場合障害につながるケースもあります。
上記の2つの点から仕様からテストを生成すればいいと考え、今に至ります。個人的には仕様駆動テスト・Specified Driven Testとかカッコつけていってますが、当たり前のことを言っているまでです。今は、「実装はテストを満たすため最もシンプルなコードである」という考え方になってます(アインシュタインの「Everything should be made as simple as possible, but not simpler.」も添えて)。
なぜ私がすべてのテストケースの実装をするのか
仕様からテストを生成すればいい、というのは分かったと。ではなぜわざわざテストケースの実装を最初にするのか。
唯一の理由は前提条件と確認事項を明確にするためです。この開発手法をBDDっぽいと呼ぶ理由がこれです。
もちろん、テストケースに説明文を書くことができるタイプのテストライブラリを利用すれば、前提条件と確認事項をテストケースの説明文に入れることがで可能です。私もTypeScriptとJestなどでテストを書いているため、基本的にはテストケースに説明文を記載できます。
一方で、テストケースの説明文だけでは表現しきれない情報もあります。
例えば「メンションがあれば通知を送る」というテストケースの前提条件としては、送信元ユーザーがいる・メンション先のユーザーがいる・メンション先のユーザーの状態がメンション可能である・メンション先のユーザーが通知を許可しているなどの条件があるでしょう。この場合は「メンション先のユーザーがメンション可能であり通知を許可している場合通知を送る」とかみたいな説明文のほうが適切かもしれませんが。
私が仕事で書いているプログラムも少なからずそういった側面があり、前提条件が複雑になりうるケースも少なからずあります。そういったものを事前に全て書いて完全な仕様を把握するという目的を持って書いています。
また、前提条件を事前にきっちり書くと、「こういう場合はどうなんだっけ」という仕様のブラッシュアップだったり「こういう実装にしたらシンプルになるな」と実装のブラッシュアップにつながるケースもあります(具体例が思いつかず恐縮です…)。
テスト = 仕様という関係性から、テストを全部書くということは仕様を俯瞰して見ることができます。全体設定の最適化もできますし、シンプルにテストを俯瞰で見るためテストコードの最適化にも繋がります。
上記の理由により、テストを完成こそさせますが、テストの実装が正しいことは一切気にしません。必要なのは前提条件と確認事項を確実に出し切ることです。そのため、大体の場合テストコードの誤りによりテストが落ちます。それはちゃんと前提条件を洗い出せていないのでは?と思われるかもし、結局実装書いてからテストを修正するからテストと実装が依存してしまうのでは?という疑問も出てきます。
これに関しては確かにその通りかもしれません。一方でこうならないように「なぜテストが落ちているのか」はしっかり確認しましょう。テストを通すためにテストを書いているのではなく、正しい仕様をテストで表現するんだというのが大事です。実装が誤っていそうならば実装のみを修正する・テストの書き方が悪そうであればテストのみを修正する。
なぜ私がテスト駆動開発を行わないのか
正直言うと、TDDのほうがよっぽどいいと思います。それはシンプルに多くの人が取り組んでいて、効果が高いことが分かっているからです。長いものにまかれろ、ということですね。
一方で私が行っていない理由はTDDが駄目だからとかそういった理由ではなく、シンプルに合わなかったからです。
上記に記載した通り「前提条件と確認事項を明確にしたい」という欲求があります。これはシステム的特性もあるかもしれませんし私の性格的なものなのかもしれません。その欲求はTDDは満たせませんでした。
また、読みやすいテストコードのために心がけること ver 2024.でも記載していますが、特に最近は同じ前提条件で異なる確認事項を複数のテストケースに分離するケースがあります(そちらのほうがよりテストが簡単にかけたり、生成AIの推論の精度が高かったりするからです)。それとも親和性が低いです(そういったものはまとめて書く広義的なTDDというのはありかもしれない)。
一方で、TDDの利点を享受できていないのは事実です。特に「リファクタリングによる変更容易性」と「テスト自体が誤っている」ものへの対処です。
変更容易性についてはTDDを行わずとも担保できるものかとは思います。いわゆるプリンシプル オブ プログラミングや、知識、経験則によるストロングスタイルでの対処です。
テスト自体が誤っている、というのは正直TDD以外で防ぐには無理な気がしています。つまり、テストで確認したい事象と実際にテストで確認している事象が異なるケース。たまたま前提条件などが組み合わさってテストが通ってしまっているケースです。
これはもちろん経験で対処していく他ないのですが、前提条件の生成をシンプルにするほか、前提条件を生成するいわゆるFixtureやFactoryなど適切に使うことで低減が可能です。
まとめ
私はTDDを採用せずに、テストケースを最初にすべて実装するという手法を実践しています。
- テストファーストは実装とテストの依存を消し、結果的に仕様に忠実なテスト・仕様に忠実な実装ができ品質が上がる
- 設計してからテストを洗い出す。これはTDDも私の手法も一緒。最重要。
- 私がテストを最初に実装する理由は、前提条件と確認事項を明確にするため
- テストケースの説明文だけ十分なことも多いが、どれだけ他のモデルなどに依存しているかを事前に把握できる
- 副作用的な利点として、事前に前提条件や確認事項を詳細に記載すると、対象の実装方法がより明確化・ブラッシュアップする(ように感じる)
- 私がTDDを行わないのは、基本的には現状のシステムや私の性格的に合っていなかったから
- 前提条件が複雑になる部分も多く、説明文だけではなく超具体的な前提条件と確認事項が欲しかった
- わざわざ自然言語で書くぐらいならコードで書いちゃえという発想
- テストを1つ1つ書いていくという仕様上、同じ前提条件で異なる確認事項を確認するテストが面倒(同じ前提条件ならまとめて書くなども広義のTDDとして良いと思う)
- テストをすべて書いてから臨むと本実装が簡単にできる、という体験があるので、あんまり食指が動かなかった
- 前提条件が複雑になる部分も多く、説明文だけではなく超具体的な前提条件と確認事項が欲しかった
どんなものにも言えることですが目的を見失わないことです。TDDも私の手法も、あくまで手法です。目的は「高い品質のコードを書く」「仕様をコードに反映する」ことです。私に合った手法としては「テストを先に全部書く」という、言ってしまえば「誤ったTDD」「アンチパターン」を実践しています。しかし(もちろんあくまで個人の感覚ですが)かなり開発体験も良くそして(仕様さえ誤っていなければ)バグの少ないコードができていると感じています。
まぁ、1個のテストをRedからGreenにするより、100個のテストが一気にRedからGreenになるのが、ただ好きなのかもしれない。
Ruby on Rails TutorialというRailsの有名なチュートリアルのではTDDが採用されています。一方でRails作者であるDHHはTDD is dead. Long living testing.(原文が消えていたため翻訳記事)と2014年に言っていますが…。 ↩︎