継続した開発のために: ユニットテストの定義を考える
「うーん。ユニットテストの自動テストが遅くてPull Requestの滞留時間が長くなってきていますね」
「ユニットテストが壊れているから直すのに時間がかかります」
このような会話をしたことがありませんか。私はあります。
このような会話をしている時ってそれぞれが、「ユニットテスト」に対して色々な意味を想像していませんか。
- 「ユニットテストって外部の依存は極力フェイク(モック、スタブ)を使いますよね。遅くなっているのって本当にDBを使っているからじゃないですか」
- 「全部の外部依存をモック化するってことですか。今ならコンテナ技術でサクッとDB立ち上げできるから、それも含めてユニットテストでいいんじゃないですか」
- 「いや、依存とかっていうよりもユニットテストって1つの関数や1つのクラスをテストするものでしょ」
- 「ユニットテストって外部から観察できる振る舞いをテストするものだから、1つの関数やクラスに限定されないですよね」
などなど。
今回はいくつかの書籍に出てきた定義や「良いユニットテストとはなにか」ということをまとめつつ、自分なりの答えを探してみました。
次回以降の記事で具体的なプラクティスについてもまとめていきたいと思います。
※ 余談ですが「モック」という言葉も人によって使っている意味が大きく違うと思います。それは今回特に触れておりません。
なぜ認識をあわせたいのか
私は「誰もが納得するユニットテストの定義を決めたい」のではなく、「チームでプロダクトを継続的に改善していくために、どこで何を誰がテストするか目線を合わせたい」と考えています。
なんならプロジェクトや技術領域によって、定義も変わって良いのですが、チームでは以下のようなことを合わせて継続的にプロダクションコード・テストを改善していきたいと考えています。
- どこで何をテストするか
- DevOps のテストフィルターで考えれば、ユニットテストでどんなバグを捕まえることを期待しているのか
- 継続的にプロダクションコードを変えていくためにどのテストからどんな価値を受け取りたいのか
- ユニットテストから得たいもの
- 統合テストから得たいもの
- 誰がどういうテストをするのか
- 開発者はどこまでテストを書くのか
- QAエンジニアはどのように関わってくれるのか
そこで、ユニットテストに関する書籍を読むことで、それぞれの定義や「ユニットテストに期待する価値」を探してみることにしました。
さっそくまとめ
- やっぱりユニットテストの言葉の定義は決まってなさそう
- ソフトウェアテストの認定試験を提供している世界的な機関でさえ公表していない
- とはいえ、「ユニットテストで何をみるか」「どういう性質を求めているか」ということにはある程度共通点がありそう
- 最近の書籍だと「外部から観察可能な振る舞いをテストする」というように1つのクラスや1 つの関数に限定していないものが割とある
- ユニットテストに求められているっぽい性質の共通点
- 早く実行できる
- いつでも実行できて、何回実行しても結果が同じ
- 外部への依存(DB、ネットワーク、他のシステム)などへの依存を(極力、一切)含まない
- 自分なりの結論をまとめてみた
- 後述する以下のような軸で整理してみた結論
ユニットテストの定義はあいまい
「まずは世界的な機関とかで定義されていないかな」などと考えながら以下の書籍を読んだところ、早速以下の記述を見つけることができました。
実は「単体テスト」には厳密な定義がなく、ISTQB(https://www.istqb.org)の用語集にもありません。いや、昔はありましたが、今はありません。それほどあいまいな用語であり、いつもISTQBなど会議でもめていました。
引用: ソフトウェア品質を高める開発者テスト 改訂版 アジャイル時代の実践的・効率的でスムーズなテストのやり方
世界的なソフトウェアテスト資格の機関でも定義がないくらいなので、それは開発者がそれぞれ違う意味を想像していても無理ないですね。
ちなみに、今(2024/06/03)検索しても「unit test」では用語集でヒットしませんでした。
代わりにコンポーネントテスト( component testing
) という言葉を使っているようです。
では、ユニットテストに関して言及している書籍の著者はどうでしょう。
単体テストの考え方/使い方
こちらの書籍です。
- この書籍による単体テスト(ユニットテスト)の定義
- 1単位の振る舞い(a unit of behavor)を検証すること
- 実行時間が短いこと
- 他のテスト・ケースから隔離された状態で実行されること
- この書籍における良い単体テストを構成する4つの柱
- 退行(regression)に対する保護
- なんらかのコードへの変更が、他の部分に影響を及ぼしていないかを確認するなど
- リファクタリングへの耐性
- テストが失敗することなく、どのくらいプロダクションコードへの変更を行えるか
- たとえば、メソッドや変数の名前を変えてもテストが失敗しないなど
- 迅速なフィードバック
- 保守のしやすさ
- 退行(regression)に対する保護
また、4つの柱はすべてを満たすことができないため、ユニットテストでは「リファクタリングへの耐性」「迅速なフィードバック」を「退行に対する保護」より優先して備えさせるということも書かれています。
The Art of Unit Testing, Third Edition
こちらの書籍です。頑張って英語版を読んだので、翻訳がおかしいかもしれません。
- この書籍におけるユニットテストの定義
- Entry Point を通して unit of work を呼び出し、Exit Point の1つをチェックする
- Entry Point および Exit Point については作者のブログを参照してください
- sumというテストを呼び出す場合
- Entry Point:
sum(1, 2)
- Exit Point:
3 という値を返すところ
ログを出力するところ
のようなイメージ
- Entry Point:
- 簡単に書くことができ、素早く実行できる
- 信頼でき、読みやすく、保守しやすい
- プロダクションコードが変更されていない限り一貫性がある
- Entry Point を通して unit of work を呼び出し、Exit Point の1つをチェックする
- この書籍における良い単体テストが持っている性質
- 素早く動作する
- テスト対象のコードを完全に制御できる
- 例: 直接サードパーティー製の依存が使われていれば、そのコードを完全にコントロールできない
- 他のテストケースから独立して実行される
- ファイルシステム、ネットワーク、データベースを必要とせず、メモリ上で実行される
- 可能な限り動機的かつ直線的であること(並列スレッドを使用しない)
テストから見えてくるグーグルのソフトウェア開発
こちらの書籍です。
- この書籍における定義
- ユニットテストという言葉を使わず、テストサイズ(S/M/L)という整理をしている
- 一般に環境から切り離してコードの一単位のふるまいをチェックする
- ファイルシステム、ネットワーク、データベースなどの外部サービスはモック、フェイクにしなければならない
- この書籍におけるSサイズテストのメリット
- 分離され、外部依存がないので、Sテストは非常に高速に実行できる
- Sテストは非常に頻繁に実行され、バグをすみやかに見つけることも多い
ちなみに@t_wada氏もいくつかの記事やスライドでテストサイズによる分類について言及されています。
たしかに、「ユニットとは何か」などと考えだすよりも、サイズという視点で整理すると色々と収まりがよさそうですね。
自分なりにまとめてみる
「ユニットテスト」「Sサイズのテスト」という表現の違いはあれど、「実行時間が短い」「他のテストケースから独立している」などの「求めたい性質」は似ているような気がします。
「実行速度が速い」や「迅速にフィードバックしてくれる」ということから私なりに予測すると、以下のような使われ方をできそうな気がします。
- VS Code などのエディタで保存するたびに、ユニットテストが実行される
- すぐに結果が表示されるので、開発者がすぐにバグや意図しない挙動を確認できる
- どこのロジックが壊れているかがすぐにわかる
また、「できるだけ外部への依存を持たない」や「何回、どんな順にテストケースを実行しても結果が同じ」などを考えると以下のような性質があるのかと思います。
- ビジネスロジックやドメインモデルのテストが中心
- 実際のDBなどを使うわけではないので、「テスト通ったぁ。じゃあ、どこも壊さず機能が動いていそう」という安心感は少ない
ということで「テストをパスした時の安心感」「実行時間が速い」という2軸で整理すると以下のような感じになるでしょうか。
また、「単一の振る舞いをテストする or 振る舞いを束ねたシナリオをテストする」、「実行時間の速さ」という2軸でまとめると以下のような感じだと思います。
※ なお、いずれの図も面積の大きさは「テストコードに占める比率」とは無関係です。
ユニットテストの定義を考えてみた
ということで、定義や期待する効果を簡単に文章化してみると以下のような形になりました(一般的なWeb FWを使用した開発プロジェクトを想定しています)。
- 開発者がテストFWやライブラリを使って書く
- エディタでコードの変更を保存するたびに実行されても気にならないくらい実行時間が短い
-
jest --watch
やvitest
で回し続けても気にならないくらい
-
- 他のテストケースから独立していて、どの順番で実行されても最終的な結果が同じ
- 将来的にはテスト実行の並列化などによる速度改善も見込める
- Web FW でいうところの Controller や Model などの外部や外部依存への接地面ではなく、ドメインモデルやビジネスロジックへのテストが中心になる
- 基本的にDIでフェイク(スタブ)の注入をしておいて、外部への依存はフェイクを使うものとなる
- そのために設計を工夫して、DBとビジネスロジックの分離、外部依存の注入を意識する
- 1つの関数、1つのクラスにこだわらず、ひとかたまりの振る舞いを確認する
※ なお「振る舞い」ってなんだよということについても、もっと深掘りできると思いますが、今回は特に触れていません。
※ これが絶対的な定義ではなく、プロジェクトや技術領域・プロダクトの進捗によっても変わるかもしれません。
ユニットテストに期待することを考えてみた
- とにかく早く、安定して動く
- 期待する振る舞いが変わった時(プロダクションコードが変わった時)に失敗してくれる
- ただし、ちょっと内部の実装が変わったくらいでは、テストが失敗しない(リファクタリングをしたとしても)
統合テストに期待することは以下のような感じになりました。
- Controller や Model などの外部や外部依存へに接地する部分を含める
- 実際のDBや外部の依存を少し含めるので、より「パスした時の安心感」が強い
- 完全にコントロールできない依存を除いて、コントロールできる依存はコンテナ技術などを使って信頼度を高める
- ユニットテストほどではないがコンテナ技術などを使っているので、Pull Request時に実行しても遅くなりすぎない
- 少なくとも Controller をインジェクトしてリクエストを再現したり、モデルに対するテストは開発者が書く
- 実際のNWを経由して、例えばPostmanなどでAPIを叩くテストはQAエンジニアと協力するかな
じゃあ、どうするか
長々と私なりの定義やユニットテストに期待することを書いてきましたが、実際にユニットテストを書くとなると以下のような課題が出てくると思います。
- テストがしやすく、変更しやすいコードを書くために最低限やっておきたいことは何か
- ユニットテストと統合テストを分けるのはいいけど、具体的にどうやって分けるのか
- CIでの使い方や実行するタイミングはどうなるのか
- 「外部から観察可能な振る舞い」とか「Exit Point」とかってなんだよ
こういったプラクティスに関しても、別途記事にまとめていきたいと思います。
以上、長い文章を読んでいただきありがとうございました。
Discussion