ユニットテストってもう言わない! CI/CD時代のテスト分類に最適なテストサイズという考え方
はじめに
以前からユニットテスト/単体テストという言葉は使いづらい、と感じており今回も旧Twitterで「テストを実行時間ベースで分類する良い言葉ないかなー」と呟いていたところ、「テストサイズのSMLって考え方があるよ」と教えて戴きました。
だいたいは教えてもらったt_wadaさんの記事にすべて書いてあるのですが、自分の整理も含めて動画にしたので、その補完記事となります。
TL;DR
- 単体テストのバベルの塔は既に崩壊
- CI/CDでの継続的テストには時間ベースのテスト分類が重要
- UT/IT/E2EではなくSMLによるテストサイズがCI/CDには合う
それは単体テストか結合テストなのか?
自動テスト、手動テストに関わらずテストの分類として単体テストと結合テストという言葉は一般的です。
ITQBではTest Levelsという言葉で定義されていますし、以下のようなV字モデルの対応表はみんな知っているでしょう。
ref: IPA ソフトウェアテストガイドブック
そう、これは誰もが知っているはず定義なのに特に 「(JUnitなど自動化された)ユニットテストを増やそう!」 という話をすると大混乱を招きます。
そもそも単体テストは 「単機能のコンポーネントをテストする」 というだけで、それ以上は言及されてないというか、ケースバイケースなので現場に任されている感があります。JUnitやRSpecを教科書的に導入している場合、それは 「関数単位のテスト」 というイメージがなんとなくあります。では、その単機能の関数がDBにアクセスしている場合は? ファイル出力している場合は? これは単体テストでしょうか? MVCにおけるコントロール層のテストは? コンテナを使ったテストは? 一連の機能だから単体テストだという人もいれば、そういうものは外部への依存だから単体テストではなく結合テストである(=だからテストダブルを挟まないとダメ)という派閥の永遠の争いがあります。コンテナを使えば環境準備が不要かつIsolationされてるからUT、そうじゃないならITという人もいる。モック等のテストダブルの使い方にも流儀があります。所謂、ロンドン派かデトロイト派(古典派)ですね。ロンドン派/デトロイト派のどちらが良いかはさておき、「単機能」 の定義がここでもブレてしまいます。
また、現場によっては適切なドライバが用意されておらず、ブラウザである機能のテストを行うことを単体テスト と呼んでる場合もあります。どう見ても単体テストじゃなくて、結合テストだ、と思う人も多いと思いますが、自動テストが普及してない現場だとそれなりにあることかな、と。
テストレベルを考える事は重要ですし、それに伴って何が保証されるかを意識することは品質保証における最初の一歩です。しかしながら、エッジケースで 「これは単体テストなのか否か?」 という議論や思考のループに陥るのは不毛ですし、なにより人によって意味が異なると話がかみ合わない/意図しない成果物ができる という非常に大きな問題が発生します。
このように単体テストのバベルの塔は既に崩壊しているので、より明確な基準が必要になります。
CI/CDにおける継続的テストと実行時間/コスト
テスト自動化、という表現を良く聞きますし使いますが、最近は「継続的テスト」という言葉が重要であると感じています。テスト自動化とは、あくまでテストが自動化されていれば良いので、任意のタイミングで手動で自動テストをキックしても構いません。時には、自動テストを流すための準備、にそれなりのコストを要するものも自動テストです。
一方で、継続的テストとはCI/CDパイプラインの中でQuality gateとしてしかるべきタイミングで自動的に実行されるテストです。タイミングは色々あります。例えばコンパイル/ビルド時、たとえばPushされたとき、PRが作成されたとき、などなどプロダクトやチームによって各々のタイミングがあると思います。ただ、共通することはCI/CDパイプラインはフェーズによって繰り返し頻度が異なる、ということです。
ローカルでのビルドは本当に頻繁に発生しますし、自分のブランチにPushする回数も相当でしょう。そこから検証環境などの共通環境向けのブランチにマージするためのPRも適切にCI/CDが回ってるなら多くの頻度がありますが、さすがにローカルビルドと同等ということは無いと思います。
そして、ローカルでビルドするたびにテストが5分とか30分とかかかると、とてもじゃないですが実用できないですよね? ビルドするたびにコーヒー休憩です。
一方で、PRのタイミングであれば、そのくらいのテストを許容できるケースも多いと思います。
こうした実行時間の差は基本的には単体テストや結合テストといった考え方と相関があります。データベースやファイルを使うテストは重いですし、E2Eのように例えコンテナにであってもデプロイしてブラウザテストなら相当に重たいからです。
しかしながら、先ほども述べた通り 「単体テストの定義」 にはあいまいな部分が多く、人やチームによって実際のものが異なります。それでも、自動テストを手動でキックしてるときにはそこまで大きな問題になりませんでした。しかし、CI/CDパイプラインをプラットフォームとして提供して、継続的テストを回す、となると曖昧だと困ります。ビルドの度にE2Eテストが走ってしまうと、test-skipまっしぐらですが、これは最悪のバッドプラクティスです。
悪魔のささやきに耳を傾けると、悪いテストによって良いテストの実行までもがスキップされてしまいます。
CI/CDと継続テストの観点から考えれば、重要なのは単体か結合かというより純粋に実行時間です。加えてDBの利用など本番環境にテストの状態を近づければ近づけるほど、コンテナや共用環境の払い出しコストも高まり、時間がかかります。その意味でも、テストレベルではなく実行時間に注目したテストの分類が重要になります。
Google流! テストサイズという考え方
軽いテストを単体テスト、重いテストを結合テストと呼ぶだけでは、前述したようにバベルが崩壊した世界では 「うちでの単体テストはこうだから!」 と定義しなおす必要もあるし、「でもこれはやはり単体では。。」みたいな不要な議論が発生します。
なので、CI/CDには時間ベースのテスト分類が必要ですが、良い分類の名前を知らなったので仕方がなく独自用語を定義して呼んでいましたが、独自用語なのでこれも説明コストが高いし、収まりも悪い。
そんなわけでもやもやしてたのですが、Googleが Test Sizes(テストサイズ) という、良い感じの呼び方を提唱してくれていました。
テストサイズでは、実行時間やテストに使用される実行場所に注目します。これによってSmall、Medium、Large、Enormousの分類に分けます。特に、SMLの3つの分類がよく使われる定義です。
Smallテストは全体で1分以下、個々のテストケースは100ミリ秒以下で実行されるテストです。基本的にはロンドン派のユニットテストで、純粋なプログラムコードで完結するテストです。DBやファイルのアクセスが必要な場合はテストダブルを利用する必要があります。
Mediumテストは全体で5分以下、個々のテストケースは1秒前後で実行されるテストです。Smallテストより実行時間に関する制約が伸びたでの、RDBやファイル、コンテナを利用したテストが可能になります。
Largeテストは「できる限り高速に」動作させるテストケースです。目安としては全体で15分程度。E2Eテストなど実際の環境やインフラにデプロイするテストがこちらに入ります。
Enormousテストは Largeテストと同じく「できる限り高速に」動かすテストですが、Largeテストよりさらに長時間の実行が許容されるテストです。例えば複数のシステムが絡む大規模なリグレッションテストなどがこちらのテストに入ります。
Googleでは各テストでアクセス可能なリソースを分類しています。
以上がテストサイズによる分類ですが、重要なのは個別の閾値や使用可能リソースではなく、明確な基準で分類できることです。組織やプロダクトによってSmallテストMediumテストの基準は議論し、カスタマイズされて良いと思いますし、特に実行時間はMediumを5分ではなく10分にするとか、プロダクト毎に適切なサイズがあるはずです。しかし、目的と基準が明確なので組織で多少カスタマイズしたとしても、言葉のブレが非常に少ないことが期待できます。これは大変プラクティカルな定義です。
テストサイズとテストピラミッド
各々のテストサイズにはメリットとデメリットがあります。Sテストは軽量なので大量のパターンを実行できますし、基本的に純粋なUTなのでテスト失敗時にもエラーの切り分けや原因分析を素早く行えます。一方でモック等を利用している場合には実際の振る舞いと異なるリスクがあります。
逆にLテストは実際のアプリケーションが適切に動くことを自信を持って言えます。一方で、実行コストや境界条件のテストが困難ですし、関連コンポーネントが多いのでテスト失敗時に原因を特定するのが難しいです。Mテストはまさしくその中間ですね。
そのため、以下のような70:20:10のピラミッド型に分布するのが望ましいとテストから見えてくるグーグルのソフトウェア開発では述べています。
これはいわゆるテストピラミッドと同様です。単純にUT/IT/E2EがSMLに変わったくらいですね。他にもテストの分布はトロフィー型等の考え方もありますが、このあたりも 「単体テストの定義」 が揺らいでるので、起こる混乱ともいわれています。XPの達人の頃から24のユニットテストの定義から。。。とか言ってたらしいし。なんだそれ...
いずれにしても基本的にはコストの低いテストをたくさん実行して、効果はあるけどコストの高いテストは少量に、というのは基本的な指針です。そのためベースはピラミッド型で良いと思いますが、あくまでベースなので実際の最適なバランスはプロダクトの性質や状況によって異なるでしょう。特にレガシーコードはSテストやMテストを書くのがそもそも困難なのでLテストから始まるはずです。そうすると初期の段階では当然ピラミッドにならないのですし目指す必要もありません。Mテストを厚くしたほうが効果が出るプロダクトも多いでしょう。この辺は、あくまでメリットデメリットの認識として活用し、実際の割合はプロダクトに効果のあるテストを書くことが何より大事です。
「分類とか割合とか議論するのは楽しいけど、単なる目安なんだから意味のあるテストちゃんと書けよ」という指摘もありますしね。それはそう。
CI/CDとテストサイズを考える
では、テストサイズを実際のCI/CDのどのタイミングで実行するべきか考えてみます。以下の表は個人的なベストプラクティスというか各テストの実行タイミングです。負荷テストやセキュリティテストは入れていませんが、それも各テストの実行時間に準じて当てはめればよいと思います。
テストサイズ | 総実行時間 | 実行者 | タイミング | 概要 |
---|---|---|---|---|
Smallテスト | 1分以下 | 人/CI | すべてのビルド時 | 純粋なコードだけのUnitTest |
Mediumテスト | 5分以下 | CI | Push時 | コンテナ等を利用したDB等も含むユニットテスト/結合テスト |
Largeテスト | 15分以下 | CI | Pull Request | 揮発性のコンテナ環境でデプロイを伴うE2Eテスト |
Enormousテスト | 1時間以下 | CI | Daily | STGなどの共用環境へのデプロイを伴うE2Eテスト/大規模リグレッションテスト |
ビルド時はやはりSmallテストでないとテストスキップが横行してしまいますので、コンテナベースのDBのテストやArquillianを使うようなテストはぐっと我慢です。とはいえそうしたテストはPush毎には実行したいので、この位置へ。
k8sやdocker-composeを使って一時的に環境を払い出してchrome-headless等でE2EテストをするようなケースはLargeテストとしています。
そして、STG環境/テスト環境などの、本番同等の共有環境にデプロイして行うテストをEnormousとしています。他のテストサイズはコンテナで隔離されているので、スケールさせやすいですが、共用環境へのデプロイはそこがボトルネックになってスケールに限度があるのでEnormousテストとして分離させました。
実際はプロダクトの大きさによってテストサイズ毎の総実行時間のタイムアウト値は変更が必要な気がしますが、これを超える時はマイクロサービス化を検討するべきなのかもしれません。
また、実際にはこれらのテストの後にEnormousテストと同じ環境を使って、手動のInteractive testingが行われるはずです。
まとめ
テストサイズの考え方をして、長年もやっていた部分が非常に整理されました。実際的な分類方法としては概ね同じ意識でいたんですが、オレオレ用語はなるべく作りたくないし、UT/IT/E2Eだとブレるしどうしたものかなー、と。
とりあえず、大事なのは以下の3点なので、これだけ持って帰ってもらえればと。
- 単体テストのバベルの塔は既に崩壊
- CI/CDでの継続的テストには時間ベースのテスト分類が重要
- UT/IT/E2EではなくSMLによるテストサイズがCI/CDには合う
それでは、Happy Hakcking!
Discussion