「単体テストの考え方/使い方」第1部を読んで考えたこと

最近テストコードの保守が足を引っ張ってしまっている気がしたので、一度テストについて学習する機会をとるべきだと思い、「単体テストの考え方/使い方」を読んでいる。 主に書く言語がRubyからScalaに変わり、テストではなく型で保証させるべき事柄も出てきたのでテストとの向き合い方を変える必要性を感じていたというのもある。 まだ途中だが、テストに関してこれまでぼんやりと感じていたことが理論的に整理され、とてもよい本だと感じる。

ドメイン層以外に対するテスト

つまり、重要なのは、単体テストにかける労力をシステムにとって非常に重要な部分に向ける、ということなのです。一方、そこまで重要ではない部分に対する検証は簡易に、もしくは、間接的に行います。ほとんどのアプリケーションにおいて核となる部分はビジネス・ロジックを含む部分、つまり、ドメイン・モデルになります。

一方、ドメインモデルではないコードには、次の3つのコードがあります:

  • インフラに関するコード
  • 外部サービスや依存関係にあるもの(たとえば、データベースやサード・パーティのシステム)
  • 構成要素同士を結び付けるコード

単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.48-49

これらの記述では、プレゼンテーション層やユースケース層のコードに対するテストはどうするべきなのか読み取れなかった。 もしかすると「構成要素同士を結び付けるコード」というのがユースケース層のコードを指しているのかもしれない。

ただ私の考えとしては、ドメインモデルはドメインを表現したものに過ぎず、それ単体ではドメインにもともと存在する課題を何一つ解決しない。 ユースケース層でドメインモデルを使うことで初めてドメインに存在している課題を解決できるので、ユースケース層のコードもまた価値があるのではないかと思う。 「これはドメインに存在するロジックではなく、アプリケーションのロジックだな」と思ったものはユースケース層に書くことも多いので、私はユースケース層についても単体テストを書いていこうと思った。

一方でプレゼンテーション層はアプリケーションにとって重要な部分とは言い難く、複雑なロジックが入り込むこともあまり無いので、統合テストでカバーすれば問題無いように思う。

(2024-01-27追記)この本を読み進めたところ、どうもユースケース層も単体テストはしない、というよりする必要がないほどシンプルに作るべきということだった。 以下の文は、たとえばビジネスロジック、プロセス外依存とのやりとり、ユーザーからのリクエストのハンドリングが全部書かれているControllerのようなクラスに対し、どのように質の高い単体テストを書いていくかということを述べた文である。

多くの場合、テストをすることが難しくなるのはテスト対象のコードがフレームワークとなる依存に直接結び付く場合です。そのような依存には、たとえば、非同期や複数スレッドでの実行、ユーザ・インターフェイス、プロセス外依存とのコミュニケーションなどがあります。

このような依存と結び付いてしまったロジックをテストするためには、その過度に複雑なコードからテストを行いやすい部分を抽出しなくてはなりません。そして、その抽出された部分を包み込む質素(humble)なクラスを作成し、その作成した質素なクラスに対してテストをすることが難しい依存を結び付けるようにします。このとき、その質素なクラスをテストする必要がないようにします。

単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.219-220

これを読む限りでは、ユースケース層に対する単体テストは行わず、統合テストやプレゼンテーション層も通したE2Eテストでカバーするのがよさそう。

私のテストの問題点と今後どうしていくか

この本を読んでいて、今まで私は何をモックするかという基準が曖昧だったので、古典学派としてもロンドン学派としても中途半端なスタイルでテストを書いてしまい、両者の悪いとこどりになってしまっていたのではないかと思った。 その結果、リファクタリングや多少の設計の変更ですぐ壊れ、その割にテストが失敗した時にどのクラスが問題で落ちているのかよくわからないということが起きてしまっていた。

では今後、古典学派とロンドン学派のどちらの立場に立つのかという話になってくるが、少なくとも今の仕事を続けている内は古典学派の立場に立つのがよさそうだと思った。 ロンドン学派のメリットに対する筆者の反論の一部に賛同したのと、現在業務で主にPlayFrameworkの仕組み上、ロンドン学派のメリットが薄れるからだ。

単体テストでは、1単位のコード(a unit of code)を検証するのではなく、1単位の振る舞(a unit of behavior)を検証するようにします。つまり、問題領域において意味があるもの(理想としては、ビジネス・サイドの人たちが有用であると考える何か)を検証するようにします。

単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.47

少なくとも、1単位の振る舞いが検証されてさえいれば、それは良い単体テストとなるのです。逆に、検証の対象となるものがそのことを満たせていなければ、その単体テストが何を検証しようとしているのかが曖昧になるため、質の悪い単体テストが作成されることになります。単体テストにおいて、各テスト・ケースがすべきことは、そのテストに関わる人たちにテスト対象のコードが解決しようとしている物語(story)を伝えることなのです。そして、その物語を伝えるためには凝集度(cohesion)を高め、非開発者でも理解できるようにすることが必要となるのです。

単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.47-48

これは本当にそのとおりだと思っていて、「このテストコードを読んだ人はこのクラスが何のためのクラスなのかわかるのだろうか?」と悩むことが今まで多かった。 私は今の仕事でレガシーコードにテストを追加するタスクも暇を見つけて行なっているのだが、対象のコードがビジネス上でどんな役割を果たすのかがわかるとコードの流れも多少見えて捗る。 レガシーコードについては対象のコードが果たす役割は誰かに聞くか不可能であれば自分でコードを読んで推測するしか無いのだが、それをテストに語らせることができれば後任の開発体験は確実によくなるだろうと思う。 今後は単体テストにはなるべく解決しようとしている問題を語らせること、それができない時はテスト対象のコードの凝集度が低くなっている可能性を疑ってみようと思う。

また、書籍ではロンドン学派のもたらすメリットの1つに以下のようなものが挙げられている。

協力者オブジェクトをモックに置き換えることはテスト対象となるクラスの検証を簡単にします。特に、テスト対象のクラスが必要とする依存が多く、それらの依存がさらに別の依存を必要とするような複数層の複雑な依存関係を築いている場合、テスト対象のクラスが直接アクセスする依存をテスト・ダブルに置き換えることで、この複雑な依存関係を断ち切れるようになります。

単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.48

この点はDIコンテナをデフォルトで採用するPlayFrameworkでは完全に無視できる。DIコンテナの仕組みに乗っかっている限りは、たとえ100個のクラスに依存しているクラスだろうがテストコード中では1行でインスタンスを取得できる。

一方でこのロンドン学派のもたらすメリットに対する筆者の反論は、PlayFrameworkでの開発においても気をつけなければならないと感じた。

しかしながら、この主張は間違った問題に目を向けています。と言うのも、本来考えるべきことは膨大で複雑な依存関係を持つクラスを検証するための方法を見つけ出すことではなく、そのような複雑な依存関係を構築しなくても済むようにするための方法だからです。

単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.48-49

依存関係がやたらに多いクラスは、公開メソッドがやたら多く、それぞれのメソッドで依存関係を使ったり使っていなかったりということがよくある。そういうクラスは凝集度が低くなってしまっている可能性があり、DIコンテナに頼っているとそれを見落としそうなので気をつけなければと思った。

テストケースの隔離の実現方針

一方、古典学派は、以前に述べたように隔離についてロンドン学派とは異なる解釈をしています。その違いとは、古典学派では、単体テストにおいて隔離する必要があるのはコードではなくテスト・ケースであり、各テスト・ケースをお互いに影響を与えることなく個別に実行できるようにしなくてはならい、と考えていることです。

単体テストの考え方/使い方 プロジェクトの持続可能な成長を実現するための戦略』, Vladimir Khorikov, マイナビ出版, 2022-12-28, 須田智之 訳, p.37

これはPlayFramework + ScalaTestでは相当難しい気がする。 PlayFrameworkのテストではGuiceOneAppPerSuiteを使うとappという不変なインスタンス変数にApplicationのインスタンスを格納し、テストスイートを通して1つのApplicationのインスタンスを使い回す。 モックを使っているApplicationのインスタンスがテストスイートを通して使いまわされるわけだから、テストケースを並列実行した場合、1つのモックに対し並列で呼び出しが行われ、テストケース間で影響を与え合ってしまう。

ではテストケースごとにApplicationのインスタンスを作り直すGuiceOneAppPerTestはどうかというとこちらはインスタンス変数appが可変になっており、テストケースの開始の度にApplicationのインスタンスを作り直してappに格納し直すという仕組みになっているようなので、やはりテストケース間で影響を与え合ってしまう。

もちろん自作関数を用意すればこの問題は解決するのだが、以前それで罠にハマったこともあり、そこまでやるのか?という気もする。 自分としてはテストスイートのクラス1つ1つが互いに影響を与え合わないようにし、それぞれのテストスイートは並列に実行できるようにしておくところまでで妥協しておくのがよいように思われる。 この場合はモックにすべき共有依存があればGuiceOneAppPerTestを、無いならGuiceOneAppPerSuiteを使っていくのがよさそう。

感想

単体テストを保守する工数にはRubyで開発を行なっていた頃から悩まされていたが、それでも退行が怖くてコードが変更不能になるよりは...と思いテストを書き続けてきた。 なので、単体テストを書く本当の目的は退行に対する保護それ自体ではなくプロジェクトの成長を持続可能なものにすることにあり、質の悪いテストを残すとその妨げになってしまうという考えに衝撃を受けたと同時に、うなずくしかなかった。 今までテストコードはおまけ程度に考え、多少ダーティに感じる書き方も許容していたが、今後はプロダクションコードと同等に大事なものであるという意識で向かっていきたい。