はじめに
ZOZOMO部プロダクト開発ブロックの木目沢です。
ZOZOMOで提供しているZOZOTOWN上での「ブランド実店舗の在庫確認・在庫取り置き」APIの開発に携わっています。
今回は、開発当初から現在に至るまでのユニットテスト戦略についてお話しします。
意識してテストを書いていたのにカバレッジが低い問題
2021年11月にリリースされたブランド実店舗の在庫確認・在庫取り置きの機能ですが、開発当初のユニットテスト方針は以下のようなものでした。
- モデルのユニットテストは必ず書く
- モデル以外の箇所は可能な範囲でユニットテストを書く
当時は実装のコードよりテストコードを先に書くといった文化はなく、レビューでテストの有無や内容を指摘する程度のものでした。
カバレッジも取っており、GitHub上では見える化していたものの、いつの間にか確認する機会も失われていきました。
もちろん、リリース前にはQAチームによるUIのテストも通り、十分なテストを経てリリースされています。しかし、当時のカバレッジは60%程度。カバレッジの数値というのは結果であってカバレッジの数値を上げることが目的ではないものの、今後安全に保守していくには心もとないものでした。
カバレッジは何%あるのが妥当か?
60%で心もとないと思ったのは、以前マーチン・ファウラー氏のテストカバレッジに関するブログを読んだことがあったためです。ポイントを引用します。
思慮深くテストを実施すれば、テストカバレッジはおそらく80%台後半か90%台になるだろう。 カバレッジの数値が低い場合、たとえば50%以下の場合は、おそらく問題があるだろう。高いカバレッジの数値にはあまり意味はない。ダッシュボードの数字に意味がなくなる助けをするだけだ。 以下の質問に「はい」と答えられるならば、おそらくテストは十分だろう: 本番環境で発見されるバグはほとんどない。そして、 本番環境でバグを出すことを恐れてコードの変更をためらうことがない。
50%以下ということはありませんでしたが、それでも「本番環境でバグを出すことを恐れてコードの変更をためらうことがない」とは言えない状況でした。
TDDとTDD is deadへの誤解
カバレッジを上げる必要があると考え、まず思いついたのは「テスト駆動開発(以下TDD)」でした。テストを先に書けば自ずとカバレッジが上がると考えました(後述しますが、この考えは間違っています)。
一方で、同時に「TDD」に関して思い出したのは、「TDD is dead. Long live testing.」という言葉でした。2014年に発表されたRuby on Railsの作者としても有名なDavid Heinemeier Hansson氏のブログです。
インパクトのあるタイトルが界隈を賑わせましたが、タイトルだけで判断すると誤ります。そして実際誤っていました。TDDは意味がない。最終的にテストがあればよいのだと。
ブログの記事をよく読むと、以下のことが述べられています。
伝統的な意味でのユニットテストはほとんどしない。
テストファースト原理主義
ユニットテストやテストファーストという表現に「伝統的な意味での」という接頭辞や「原理主義」という接尾辞がありました。伝統的とか原理主義というのは、どのような意味なのか、以下抜粋します。
私は伝統的な意味でのユニットテストはほとんどしない。すべての依存関係をモックにし、何千というテストが数秒で終わるようなユニットテストのことだが。
テストファーストのユニットテストは、中間的オブジェクトや間接的で過剰に複雑な構造を生みがちだ。「遅い」ものをすべて避けようとするのがその理由で、データベースやファイルIOなどを避ける。ブラウザを使ってシステム全体をテストするのも避けようとする。
批判しているのは、モックを大量に使ってすべてをテストファーストで設計する手法であったり、データベースやE2Eテストも避けようとするやり方であったりします。
書籍「テスト駆動開発」の付録では、TDDの歴史の流れから説明されていて、David氏がどのような経緯でこの考えにたどり着いたかが詳しく説明されていますのでご一読されることをおすすめします。
もう一度TDD。そしてTDDとは何か?
書籍「テスト駆動開発」にきちんとTDDとはなにかということが記載されています。
開発者が設計の治具としてテストコードを同時に書きながら開発と改善を回していくというTDDの姿(KentBeck/テスト駆動開発より)
これをもう一度見直そうと書籍を読み直しました。
そこで、気がついたのは以下のようなTDDにおけるテストと実装の流れでした。
- テストを書く。実行すると実装前なのでエラーになる(RED)
- グリーンとなる実装を書く(GREEN)
- リファクタリングし、詳細に実装していったり、整えたりする(REFACTORING)
- 以下繰り返し
つまりテストを最初に書くテストファーストだけがTDDのすべてではなく、実装し、テストを頼りにリファクタリングとしていく全体の流れこそがTDDです。
プロジェクトへのTDD導入
ここまで理解したところで、TDDをプロジェクトに導入しました。徐々にカバレッジも上がりましたが、あくまでTDDはタスクを実現するための設計手法であって、品質を保証するためのものではありません。テストを先に書けば自ずとカバレッジが上がると当初は考えましたが、TDDとカバレッジの関連は本来ありません。もちろん、TDDによって他のテストも書きやすくなり、結果的にカバレッジが上がっていくことはあると思います。
TDDを主にモデル層に導入したところ気がついたのはビジネスロジックが「モデルだけ」に集まることです。タスクを実現する実装コードをモデルだけで実現するように書いていくことで、他の層やSQLに書かれることがなくなりました。 この点がTDDにおいて重要なところになります。テストによって設計を駆動していく手法、まさに、Test Driven Developmentということです。
データベースのテストもAPIのテストもやる
TDDを進めていくにつれて、データベース接続のテストや、APIのコントローラーのテストなどモデル以外の層のテストも書くようになっていきました。この辺りをモックにしてテストの実行時間を短くするという考えもあるかと思いますが、それこそまさにDavid氏が批判してきたところです。
現在では、データベースのテストもJavaであればDBUnitを使うことで、Spring BootやMyBatisと連携したテストが書けます。コントローラもSpring BootではWebMvcTestが容易に使えるなど、テストツールが充実しています。また、時間がかかるテストもCIで回せばそこまで大きな負担とはなりません。
ユニットテストに加えて、E2EのテストもKarateなどで容易に導入できます。弊チームでも導入し始めており、仕様の確認から実装後の確認までE2Eテストを活用しています。
こうして、プロジェクトではTDDやその他ツールを活用し、充分に成果があがるようになってきました。ここでさらに社内にもTDDを広めようと2つの活動を開始しました。
TDDを活用したライブコーディング会
「ブランド実店舗の在庫確認・在庫取り置き」APIの開発にあたり、開発当初から相談に乗っていただいた技術顧問のかとじゅんさんを交え、TDDを活用したライブコーディング会を定期的に開催しています。
もともとはドメイン駆動設計を実際のソースを書きながら学習していくことが主目的でした。それをTDDで実践したほうが理解しやすいと、かとじゅんさんからのご提案でこの形になりました。
社内においてもTDDをすぐに実践できるぐらいに広められる良い機会となりました。
TDD写経会
書籍「テスト駆動開発」の付録にある通り、写経も試してみた結果、理解が深まったため、社内で写経会もはじめました。
当ブログ執筆時点(2022年08月16日)で20回開催され、都度みんなで理解を深めています。
テスト駆動開発は、実際に手を動かしてみないと理解が難しい技法です。本書も、読んだだけでは深い得心には至らないでしょう。しかし、テスト駆動開発の良さ、強みは手を動かせばわかります。なぜなら、TDDの本質は精神状態のコントロール、不安と自信の制御にあるからです。結果(書かれたコードとテストコード)ではなく、過程(思考プロセスとリファクタリング)に本質があります。(KentBeck/テスト駆動開発より)
書籍に書いてあることを理解するには、実際に手を動かして試してみることがTDDにおいては理解を深める近道で、書籍のサンプルコードは本当にゆっくりしたペースと手順で解説されています。
1行1行そのままサンプルコードを写して、テストを実行することで初めて気づくことが多いです。
以下の画像は、写経会で私が共有した感想になります。
TDDのやり方とコツ
TDDのやり方は先程紹介したとおり、テストを書く。グリーンになるように実装を書く。リファクタリングをする、という流れを繰り返す単純なものです。しかし、単純だからこそ、うまく実行するコツが必要です。ここで書籍や、ライブコーディング会、写経会を経て掴んだコツを紹介したいと思います。
(1)テストを書く。実行すると実装前なのでエラーになる(RED)
TDDにおけるテストとはタスクのことです。タスクを実現するようなテストをまず書くことでそれが設計となります。そのため、まず最初にすることはタスクの洗いだしです。タスクひとつひとつがテストとなり、テストの内容が設計となり、実装されていくイメージです。
テストメソッド名、つまりタスクは「日本語」で書いています。タスクをそのままテスト名にすることで、実装のイメージがつきやすくなりました。日本語なんてと思われる方も多いかと思いますが、文字コードの問題も起こらず使用できています。
例えば以下のようなイメージです。
public class StringObjectTest { @Test public void 2つの文字列を接続し結合した文字列を返す(){ StringObject str = new ReturnString("str"); str.concat("concatString") Assertions.assertEquals("strconcatString", str.value); } }
(2)グリーンとなる実装を書く(GREEN)
まずは、最短でグリーンになるように書くことが大事です。データベースに接続するようなタスクであれば、とりあえずMapで代用しても問題ありません。文字列を返すようなメソッドであれば何もせず適当な文字列を返しても大丈夫です。このような実装を仮実装と言います。その後はGREENを維持しながら実装とリファクタリングを繰り返していくことで安心して本実装ができます。
先の例を実装すると以下のとおりです。本番用のコードとしてはありえないと思いますが、まずは最短でグリーンになる実装を書くのがコツです。
public class StringObject { String value = "str" + "concatString"; public void concat(String str){ } }
(3)リファクタリングし、詳細に実装していったり、整えたりする(REFACTORING)
TDDで一番使われるリファクタリングのテクニックは「重複の除去」になります。そして、TDDでいう「重複」とは「テストコード」と「実装コード」間の重複や、「文字列や数値」の重複を含みます。例を挙げます。
public class StringObjectTest { @Test public void 2つの文字列を接続し結合した文字列を返す(){ StringObject str = new ReturnString("str"); str.concat("concatString") Assertions.assertEquals("strconcatString", str.value); } }
テストは上記のようなコードでした。それに対しての仮実装は以下のようなものでした。
public class StringObject { String value = "str" + "concatString"; public void concat(String str){ } }
この仮実装とテストを比較すると、重複しているのは「str」や「concatString」の文字列になるので、それを除去していきます。
以下はリファクタリングした例になります。
public class StringObject { String value = "str"; public void concat(String str){ value = value + str } }
これで、「concatString」が除去され、少しまともな実装コードになりました。この繰り返しできちんとした実装コードにしていくことができます。しかもテストが既にあるので、安心してリファクタリングが可能です。
まとめ〜いまさら? いまこそTDD
KentBeck氏によって書籍「テスト駆動開発」が最初に出版されたのは2002年。それからちょうど20年が経ちました。今でもTDDのやり方に慣れない、導入できない、内容を誤解している方も多いのではないでしょうか。
いまこそTDDを見直してみてプロジェクトに導入してみてください。
今回は、弊チームでのユニットテスト戦略のテーマでお伝えしました。開発当初はモデルへのユニットテストのみを重視していました。しかし、TDDを見直すことで、仕様の策定からタスク化・設計・各層における実装のテストまでプロジェクトのサイクル全般をテストでカバーする開発スタイルへと変化しています。
ZOZOMO部では、TDDはもちろん、サーバーレスアーキテクチャやイベントソーシング、ドメイン駆動設計などを活用しサービスを成長させたい仲間を募集中です。ご興味ある方はこちらからぜひご応募ください!