🛠️

【単体テスト負債論】単体テストのテスト対象【Private メソッドはテストしない】

2024/06/13に公開

単体テストは必要なのか

単体テストについては、昔からよく話題になりますし、自分の身の回りでも紛糾を見ることがあります。
まず自分は、「単体テストはコストが高くなりがち、費用対効果の高いテストを残したい」と考えています。

単体テストを書くべきなのか、単体テストとは何なのか。テスト実行時間、安定性(Flakyさ)、メンテナンス性などの切り口で語られることは多く、それらは大いに賛同するものです。

それでも多くの悩みを目にします。そこで別の側面から考えてみます。
単体テストはチームの開発の仕方とセットで考え、「開発成果物とする最小の単位でテストを書く」ということです。

前置き

単体テストは何か

まずテストは、仕様通りに動くことや、動作を検証することなどを指していると思います。
品質を可視化したり、品質を向上したり、維持したりするためのものです。

「単体」ですが、Test Sizes の文脈で語られるように、まずもって曖昧です。

https://testing.googleblog.com/2010/12/test-sizes.html

ロンドン派 or デトロイト派の話もよくあがり、話をより紛糾させます。

https://zenn.dev/yum3/articles/i_unit_test_schools

また、Test Trophy の話もだいぶ広まったと思いますが、「単体テストと結合テストの比重」の話もあります。
なお、Test Trophy で大事なのは「単体テストと結合テストの比重」だけでなく、細かい単位の開発時のフィードバックを回すためには、単体テスト以上に最速の実装フィードバックである、型によるコンパイラ検出と静的テストがとても大事だということです。リファクタしたときに最速でエラーが検知できます。
https://swet.dena.com/entry/2023/11/13/170000#統合テストの比重を高くする考えについて

単体テストは存在するだけでランニングコストがかかる

テスト全般に言えることですが、単体テストについても保守が必要です。書いて終わりではありません。

テスト対象やモック対象の仕様が変わったりリファクタリングした際には変更が必要なことが多いです。
使っているライブラリのアップデートのために保守が必要です。
新しいメンバーが読んで理解するためのコストがかかります。
単にテスト実行時間にも関わるため、開発生産性にも関わります。
Flaky なテストは開発者体験を損ないます。

そのため、コスト対比に優れた単体テストを残したいです。無闇に書いた単体テストコードは、モックが多く、コード量が多く、理解しづらく、ちょっとのリファクタで壊れるコードになりがちです。
自分自身が初期実装途中に安心を得るためのテストコードのために、将来のランニングコストを肥大化させるような例を多く見てきましたし、自分も残してしまうことがありました。[1]

これに大きく関連して、プライベートメソッドのテストは避けるべきという話があります。(もちろん例外は有ります)
https://t-wada.hatenablog.jp/entry/should-we-test-private-methods

内部の実装に対するテストはリファクタリングの妨げになりがちです。自動テストの助けを借りて積極的にリファクタリングを行いたいのに、その自動テストがリファクタリングの妨げになる。これはとても皮肉な状況であり、避けられれば避けたいものです。

これらのように、単体テストが負債だと言われることは多いと思います。負債は資産と表裏一体ですからある程度仕方ないと考えますが、不当に大きな負債は背負いたくないものです。
https://zenn.dev/miketako3/articles/b6bb71c8d9946e

開発の仕方とセットで考える

あなた、もしくはあなたのチームは、「開発」をどの単位で行うでしょうか?
開発計画ではどの粒度まで分解し、チケットはどの単位で切って、どの単位でカンバンに載せていますか?開発対象の Issue はどの単位で切られていますか?どの単位で PR を出すでしょうか?

シンプルに、単体テストは「開発成果物とする単位」 で行います。

main ブランチへの PR を出す、その単位でテストを書くというのを基本にすれば良いのです。

  • WebAPI 1本を作る実装タスクであれば、その WebAPI In/Out についてテストする
  • ユーティリティクラスを作るタスクなのであれば、そのクラスの機能をテストする
  • repository クラスを作成する開発タスクであれば、そのクラスの機能をテストする

前セクションにて「内部実装詳細のテストはしない」という引用をしましたが、何が内部で、何が外部なのでしょうか。
たとえば OSS でもライブラリでもない自社プロダクトにおいて、「クラスや関数の Private / Public という可視性」=「内部実装詳細であるかどうか」なのか? No だと思います。

内外は、開発タスクや責務に対しての視点、成果物の粒度で考えると良いです。

画面機能をユーザに提供する場合、これは明らかに外部です。テスト対象です。
WebAPI を別のシステムやチームに提供する場合、これも外部です。テスト対象です。
ユーティリティクラスを作り、別のクラスや後続の開発タスクで多く利用されるというのであれば、これもテスト対象にすると良さそうです。
限定されたドメイン下や特定のユースケースから呼ばれる、使い道が限定され最初から判っている、ただ単に外に export されただけの Public 関数を「外部への提供か」と問われると、これは No と言えることが多いと思います。

つまり、成果物に至るまでの途中、部品は全て内部詳細 とも捉えられます。

成果物、PR の単位は、あなたのチームの開発の仕方、チームが認識している単位に大きく依存するでしょう。
プログラムの詳細設計を細かくやり、エンジニアの役割分担が進んでいるような開発体制では、クラスのみを作ったり、小さい単位で改修を行い、提出するようなことも多いでしょう。
一方、インフラが整った大規模企業や、モダンな開発環境を使ったシステムやマイクロサービス化されたシステムでは、一定のビジネスドメインを持ったまとまりを持つ開発の進め方をすることが多いでしょう。
このようなタスクの単位はチームの文化に根差すもので、初期実装時だけでなく、保守・リファクタをするときにも基本的に同じ考え方で進められることが多いと考えます。

これらを踏まえ、単体テストは「そのチームにおいて、その開発・実装タスクの成果物をテストする」という考え方にすると、ぴったりハマるように感じています。

ケーススタディ / 身近なケース

私は主に現在、自社プロダクトを開発しています。
代表して、関与する WebAPI を持つシステムを 2 パターン取り上げます。

  1. レガシーなシステムで、大きく改善も回す気はないし、改修も最低限の受け身でしか行わない
  2. 新規システムで、常時改善は回すし、機能追加も自主的にどんどん行って行く

1 に関しては、もうどこをどう触ってどこがおかしくなるか分からない…といった、たまによくある状況であり、何か異常が起きたときのデバッグもかなり辛いという状況でした。この場合は Private レベルまでテストしたくなりました。それがリファクタの助けにもなりました。

一方で 2 に関しては、改修もリファクタも常時ガンガン回します。エンジニアはドメイン責務を広く持っており、特定のクラスや関数だけを意識することは無く、その WebAPI システムが果たすべき役割、機能を意識して、その単位で開発を回しています。
このシステムの責務は OSS やライブラリの提供ではないので、「特定の関数の In/Out を変えないまま関数内部のみをリファクタをする」みたいな考え方もほぼありません。リファクタは全体視点で行うことがほとんどでした。
この場合はもう、WebAPI の In/Out 部分こそが大事で、それ以外は全て内部実装と言えるような状況です。この状況で細かくテストを書いても費用対効果が低く、最重要なのは「WebAPI 全体を通した機能テスト、結合テスト」であると考えます。

余談

上記は基本的な考え方であって、これに付随して「特定の異常系などのために細かくテストする」ようなことは有って良く、必要だと考えます。
開発の単位が大きくなる際に、PR を分けるようなことも多いとは思います。その際に必ずしも一致するとは言えなくなるケースもあると思いますが、開発者自身やチームは「この成果物単位の一部だよね」という認識はしていると思います。
まずは、チームが認識している成果物単位、プロダクトの単位でテストすることが大事であり、必要に応じて、不足する部分の補完を考えてテストコードを書くように進めると良いと考えています。

脚注
  1. 自分のためだけであれば、書いてすぐ捨てればランニングコストはかかりません。 ↩︎

Discussion