今回のテーマは、テストについてです。
僕はプログラミングを始めて数十年になりますが、最近までテストを運用に組み込むことについて確信が持てていませんでした。
要するに、何のためにテストするのか、本当にそのテストは効果的なのか?と。
今作っているプロダクト(Webサービス)で、ようやく効果的なテスト運用のノウハウが溜まってきました。
ここではそれを紹介します。
テストの目的
まずこれを決めておかないと、色々と方針がぶれてしまいます。
なので初めに、明確にしておきます。
それはズバリ、開発効率の最大化です。
品質保証などといった高尚で尤もらしいことを言うつもりはありません。
とにかく、開発効率の最大化です。それのためだけに、テストをします。
結果的にそれが品質を高めることになるかもしれませんが、それはあくまで結果です。目的ではありません。
テストの手法
今回のプロジェクトは、いわゆる一般的なWebサービスです。
構成は、Kotlin(Java)によるサーバサイドと、JSによるクライアントサイドの基本的な構成です。
なので考えられるテストの手法としては、主に以下になります。
- サーバサイドのテスト(ex. JUnit)
- クライアントサイドのテスト(ex. Mocha)
- ブラウザを使ったE2Eテスト(ex. Selenium)
結果としては、現状で採用しているのは1番目のサーバサイドテストのみになります。
他のテストも試しました。ただし目的としている開発効率の最大化を考えたときに、後者二つは外しました。
理由は後述します。
テストのフレームワーク
Javaベースのサーバサイドのテストフレームワークとしては、言わずと知れたJUnitがあります。
今回のプロダクトは、Spring Boot (All Kotlin) で構成されています。
結果として採用したのは、Spring Test + JUnit5 という、最も一般的なフレームワークです。
Kotlin という言語のおかげで、JUnit5 ベースでも比較的読みやすいテストコードを書くことができるからです。
テストで使う言語について
ここはちょっと蛇足ですが、もし仮にメインプログラムが Java で記述されていた場合に
テストで使う言語は何にすればいいでしょうか。
テストだけ Kotlin で書くことも、もちろん可能です。
しかし僕は、あまりオススメしません。過去にそういう事をやったこともあります。
結果起きたことは、テストコードのメンテナンスがきつくなる、です。
扱う言語を一つ増やすということは、エンジニアにとっては意外に厳しいのです。
僕は十を超える言語を扱うことが出来ますが、それでも尚、厳しいです。これは事実です。
特にメインコードを書きながらテストコードを書く場合、言語が変わると頭の切り替えがうまくいきません。
なるべくなら、メインコードと同じ言語を採用する事をオススメします。
採用するテストの種類
これも、議論の余地があるところだと思います。
以下のようなものが考えられます。
- 単体(ユニット)テスト
- 結合テスト
- シナリオテスト(機能テスト)
先ほども言ったように、今回のプロダクトは SpringBoot です。
なので Spring Component が、テストの最小単位になります。
単体テスト
単体テストの場合、モックなどを使ってそのComponent(以下、クラスと略)をテストします。
このテスト手法の場合、かなりモックを書く工数が掛かってしまいます。
目的に立ち戻って開発効率の最大化と考えると、テストを書くのに掛かる工数が、結果に見合っていないと考えました。
間違えないでほしいのは、これはあくまでこのプロダクトの現状に合わせて出た結論だということです。
プロダクトはローンチしてまだ2年、よってかなり仕様変更や機能追加が大幅に入ってくるフェイズです。
このフェイズで、単体テストは効率最大化にはならない、という判断です。
ただし、いくつかのクラスについては単体テストが可能なものもあります。
例えばバリデーションや計算ロジックのみのクラスだったり、ユーティリティ的なクラスだったり。
これについては、単体テストを実施しています。
残るは結合テストとシナリオテストですが、これは途中で方針が変わりました。
シナリオテスト
最初は、シナリオテストを中心に書いていました。
つまり、実際の業務シナリオに沿ったものをコードベースで書いていました。
「会員登録 → プロフィール変更 → メッセージ送信」などのように。
しかし、ある時期を起点に、結合テストを中心としたテスト方針に変更しました(理由は後述)。
結合テスト
結合テストは、色々な実装方法があるとは思いますが
ここでは以下のように定義しました。
「一つのメインクラスをテストするのだが、その内部で呼ばれている依存コンポーネントも結合してテストする」
例えば、メッセージを送信するテストを書くとします。
そのとき、間違いなくそのSpring Componentは他のComponentに多数依存しています。
代表的なのが、データベースにアクセスするクラスです。
完全な単体テストの場合、この部分(自分以外のクラス)は全てモックにしてテストを実施します。
しかし今回の結合テストの方針では、実際にDBに接続してテストします。
(あくまでDBアクセスのロジックを通すだけです。DBアクセス部分のテストはここでは実施しません)
これには色々意見があるとは思いますが、開発効率の最大化という観点で考えたとき、それが一番効率的だという判断でそうしています。
テストの実施タイミング
これはとても、重要なことです。
テスト運用が正しく機能するためには、いつテストを実施するかが非常に重要なポイントとなります。
ここは先に結果を書きます。以下のタイミングでテストを実施しています。
- ローカルでの開発中(これは開発者に委ねられる)
- PullRequestのタイミング
- メインブランチにPushしたタイミング
大事なのは、PR時の実施です。
これを適用することで、PR運用をしている限りは必ずテストを保守しなければなりません(テストが失敗しているとマージできない)
具体的には、Jenkins の Github PR Plugin を使っています。
(ただしこのプラグインは2020年現在開発が止まっているので、今から使うのであれば他のものを採用することをオススメします)
2021.3追記:現在は GitHub Branch Source Plugin に乗り換え(https://plugins.jenkins.io/github-branch-source/)
メインブランチでのテストは、カバレッジを継続して計測するために適用しています(これは後述します)。
テスト運用を継続するために必要なこと
これは実際にテスト運用を実践するときに、非常に重要な事です。
なぜ多くの現場でテスト運用が実践できていないか。それは、これが理解できていないからです。
テストを書くためには、まずテストフレームワークを決める必要があります。
しかしそれだけでは不十分です。
大事なのはそのプロダクトに合わせた専用のテスト基盤を作ることです。
例えば、以下のようなものがあります。
共通で使えるAnnotation Class(もしくは基底クラス)の作成
Spring Test で書く場合、テストクラスに Annotation を付けるだけで
テスト実行時に必要な事前準備を実施してくれます。
しかし、正しい Annotation を付けるのは、実はそれほど簡単ではありません。
下手をすればそれを試行錯誤しているだけで一日が終わってしまいます。
よって、どのテストクラスにも使える、共通の Annotation Class を作っておくのが便利です。
特に結合テストをサクサク作成するためには、これは必須です。
テスト用Factoryクラスの作成
他にも結合テストを書く場合、事前にそのロジックを動かすためのテストデータが用意されている必要がある場合があります。
例えばメッセージを送信するテストを書く場合、事前にそのメッセージを送信するユーザ情報が必要ですよね。
こういった事前データを作成するとき、各テストケース毎に書いていると色々な問題があります。
- 作成工数が掛かる
- 間違った事前データを作成してしまい、正しい結合テストになっていない
これらを解決するために、共通のFactoryクラスを用意します。
例えば「ユーザ情報を作成する」「商品情報を作成する」などです。
モックヘルパーの作成
モックの作成はなるべく避ける方針にしていますが、それでもモックを書く場面というのは
少なからず発生します。
主なものは、外部サービスに接続するような機能をテストするときです。
さすがに自分たちが制御できないロジックに依存してテストを書くのはリスクが大きいので、そういうときはモックを利用します。
具体的には、Mockito (+ Kotlin Mockito)を使っています。
適切なモック処理を書くのは、前述したように非常に時間が掛かります。
なので、どこからでも使えるように共通のモックヘルパーを用意しておくと、工数の削減になります。
まとめ
上で、3つの共通処理の作成について説明しました。
これらは全て「テストを書くための工数削減」に繋がります。
逆に言うと、これらを作っていないとテストを書く工数が膨れ上がり、結果的にテスト運用が回らなくなってしまいます。
改めて言いますが、テスト運用を実践するためにはフレームワークの採用だけでは不十分で
プロダクトに応じた共通処理を書く(そしてそれを充実させ続けていく)ことが必要不可欠になってきます。
テストを書く工数が、開発を潤滑に進めるのに必要な工数を上回ってしまった瞬間、
テスト運用は崩壊します。
採用しなかったテスト手法について
最初のほうで、クライアントサイドのテストとE2Eテストについて採用しなかったと書きました。
理由はいくつかありますが、大まかに言えばこれらはフロントエンドエンジニアに依存するものなので
(僕の主力範囲がサーバサイドということもあって)今のチーム構成では適用しなかった、というだけです。
チーム構成によっては、採用できるところもあると思います。
テスト運用が成功するかどうかは、チームメンバーの技術力(及びテストへの知見)によるところが大きいです。
なので、例えばクライアントサイドのテストだけ運用に乗るチームもあるかもしれません。
僕自身、SpringBootベースのテスト運用を回せるようになるために、3~4年の試行錯誤が必要でした。
ただ単発のテストを書けるだけでは、テスト運用がうまくいかないことは、身をもってわかっています。
E2Eテストにも過去に何回もチャレンジしました。しかし現状、まだ運用には載せられていません。
おそらく、もっと優れたE2Eフレームワークが必要です。
適用するテスト手法を変更した件について
プロダクトローンチ直後は、シナリオテストをベースに書いていました。
しかしこれには、いくつか問題が出てきました。
機能を分割できない
シナリオテストを書くためには、多くのコンポーネントに依存しなければなりません。
プロダクトが大きくなるに連れて、全体のコンポーネント数は間違いなく肥大していきます。
つまり、プロダクトが成長するごとに、シナリオテストを書く負荷(そして動かすために必要な時間)が増えていってしまいます。
原点に戻りましょう。
開発効率を最大化するためには、プロダクトが成長し続けていっても高い効率を継続しなければなりません。
そのためには、Componentの機能分割が必要です。
一つのテストを動かすために必要な依存関係を、できる限り少なくする構成を意識するようにしました。
Spring Framework には、それを可能にするための仕組みが備わっています。
それは @Import です。全ての Spring Component には、明示的にそのクラスが直接依存するクラスを
@Import によって明記します。これによって、必要最小限の依存関係だけでテストを実施することができます。
そんな面倒なことをしなくても、パッケージを丸ごと ComponentScan すれば、依存関係は一発で解決します。
しかしそれは、プロダクトが大きくなったときに外せない重しとなってのしかかってきます。
なので、面倒ではありますが、将来性を考えて依存関係を明示的に書くようにしました。
結果、うまくいっています。
テストデータ問題
シナリオテストを書くためには、予めテストデータの一式が必要なことが多いです。
以前はそれを flyway で用意し、テストコードから直接それを参照していました。
例えば
userId = 1
のようなテストコードです。
もしくは、データを作るために他の多くの機能(Component)をシナリオテストの中(@Before)で呼び出していました。
これは最初は便利でしたが、やはりプロダクトが大きくなるに連れて
問題が出てきました。
- テストデータに多くのテストが依存しているため、テストデータを迂闊に変更できない
- 多くの機能を呼び出すため、データを作るのに時間が掛かりテストの実行に時間が掛かってしまう
これを解消するために、上で説明したような Factory クラスを採用することにしました。
これによって、テストデータは毎回 Factory クラス経由で作ることになるので予め用意しておく必要がなくなり
他の Component に依存せずにテストデータを作成できるため、データの作成に掛かる時間も劇的に速くなりました(純粋にINSERT文の実行に掛かる時間だけになったので)。
フェイズに応じたテスト手法の変更
上で書いたようなことが、今後もまた起きるかもしれません。
プロダクトの成長曲線はまだ上り調子ですが、いずれ必ず平行に近づいてきます(いわゆる保守中心のフェイズ)。
そうなったときは、また新たなテスト手法に変えていくかもしれません。
しかしいつの時も、原点を忘れないようにします。
開発効率を最大化するためのテスト
この軸を守っている限り、どんなときでも適切なテスト手法を採用する手助けになると信じています。
プレゼンテーション層のテストについて
一つ書き忘れていたことがありました。
このプロダクトはWebサービスですから、フロントエンド(JS層)とバックエンド層(Kotlin層)の間には
Kotlinで書かれたプレゼンテーション層(Spring MVC)が存在します。
そこのテストはどうするのか?
これも、いくつかの試行錯誤がありました。
最初は、プレゼンテーション層に対してもJUnitベースのテストを書いていました。
しかし今はやっていません。
その理由は、プレゼンテーション層にビジネスロジックを入れないというプロダクト内ルールを設けることによって
全てのテストをバックエンド層に集約することが可能になったからです。
プレゼンテーション層では、エントリポイント(API)毎にバックエンド層の関数を一度だけ(ここ重要)呼び出します。
複数の関数呼び出しを許可してしまうと、プレゼンテーション層にロジックが混入する可能性が高くなります
もちろんその前後に、Web層ならではの処理(認証や、フロントエンド層に渡すためのデータ変換など)もあります。
しかしそれを一つ一つテストしていたら、開発効率の最大化に繋がらないと判断しました。
(ここのテストはバックエンド層とのテストと重複することが多くなってしまい、書いた工数に見合った結果が得られない)
ここの部分のテストは、将来的には(現在ではまだ実施できていませんが)E2Eテストでカバーします。
脱線1:テストカバレッジについて
ここからはちょっと脱線します。
テストカバレッジについてです。
結論から言うと、テストカバレッジの計測は継続的なテスト運用のために、実施した方が良いと感じています。
理由は以下です。
- テストしていないコードを具体化できる
- 使われていないコードを具体化できる
前者は、言わずもがなだと思います。
カバレッジを見れば、テストを通っていないコードが一目でわかります。
意外と大事なのは、後者の方です。
つまり、テストを書くことによって、不要コードを見つけることができるのです。
もし使われていなければ、そのコードを削除するチャンスです。
開発効率を最大化するためには、保守するコード量を最小限に留めておくことが非常に重要です。
特に機能追加や変更が多く入る時期は、プロダクト内のデッドコードが多数発生することになります。
そのとき、もしそのコードをそのままにしていると、部屋の中にゴミを積んだまま開発作業を続けていることになります。
そのゴミは最初は気にならないかもしれませんが、いつか必ず開発者の作業効率を落とす魔物となって襲い掛かってきます。
気づいたときにゴミは捨てる習慣をつけておくことが大事です。
ちょっと見落としがちですが、「テストコードからしか呼ばれていない残骸のコード」なんてものも意外とあったりするので注意しましょう
脱線2:疎通テストについて
またもや脱線話です。
いわゆるカバレッジを通すためだけの疎通テストについてです。
これ、どう思いますか?
何の意味も無いわ、このたわけ!って怒る人もいますよね?
僕も以前はそう思っていました。
しかしテスト運用をしていて気付いたのですが、疎通テストには思わぬ効果(しかも価値が高い)があります。
リファクタ時の不具合混入に気付ける
例えば、クラスが肥大化してきたときに、クラスの分割をしたとしましょう。
もしくは複数ある共通処理を一つにまとめるなど、リファクタは開発効率を上げるために有効な手法ですから
実施することも多いと思います。
そんなとき、コンパイルエラーはIDE上ですぐに気付けますが、実行時のエラーは実際に動かさないと気付けません。
疎通テストはこんな時に効果を発揮します。
実行時エラーが発生すれば、必ずテストは失敗します。
よって、不具合混入に気付くことが出来ます。
もし疎通テストすら書いていない場合、そのコードが動くのは実際にプロダクトをリリースした後になります。
そこで不具合が発覚するのと、テストで気付けるのには、天と地ほどの違いがあること、わかりますよね?
疎通テストは、開発効率の最大化という観点で、間違いなく効き目があります。
逆に言えば、カバレッジが通っただけでは、テストが完全とはとても言い切れません。
カバレッジとプロダクト品質には多少の相関性は存在しますが、あくまで目安程度に考えるほうが無難です。
クラス(関数)の不適切な構成に気付ける
例えば、ある関数の疎通テストを書くとしましょう。
そのとき、以下のようなことに気付くことがあります。
- 関数の引数が多すぎる
- 関数を呼び出すための依存関係(や事前準備)が複雑すぎる
- 関数やクラスの役割が曖昧、もしくは多すぎる
こういったことは、実コードを書いているときには気付きにくいのです。
もしくは、そういったことを意識せずにバーッと書いてしまった方が楽なので、開発効率的にはそういった進め方もアリです。
それを後で整理するための材料として、テストを利用するのです。
- 実コードを書く
- テストコードを書く
(2-a. リファクタする) - テストを成功させる
- コードがマージされる
この流れが出来上がるのです。
もちろん、2のときに疎通だけでなく機能的なテストもした方がいいのは間違いありません。
しかし、時間的猶予が無い場合もあります。そんなとき、とりあえず疎通だけしておくことでも
非常に価値が高いのです。
テスト運用をし続けた結果
今、こちらのプロダクトでは非常に高い効率で開発が出来ています。
それには様々な要因がありますが、その一つとしてテスト運用を続けてきたからだというのは
間違いなく確信があります。
よく「スタートアップではテストを書いている暇が無い」と言う人もいますが
それはやり方を適切に選べていないからです。
このプロダクトはローンチから2年が経過し、その間毎週のようにリリースをしていますが
テスト運用はローンチ直後から今まで継続して実践できています。
もちろんそのための道のりは決して楽ではありません。
しかし、もしテスト運用を止めてしまったら、その1年後に開発効率が大幅に低下することはわかっています。
だから、テスト運用を継続しています。
開発効率を最大化するために。