じぶん対策

日々学んだことをアウトプットして備忘録にしています。

ソフトウェアのユニットテストにおけるモック化を巡った派閥と費用対効果について考えてみる回

はじめに

以前からたびたびソフトウェアのユニットテストに関する記事を公開してきました。

過去記事
- ユニットテストについて考えてみる。PHPUnitでモックを使用して小さいユニットテストを書く。
- Mockeryの基本的な使い方
- ユニットテストに関する前提知識

今回はその続きとして、ユニットテストの流派と、費用対効果について考えてみたいと思います。
そのうえで現在担当しているプロダクトで採用するべき手法について再度考えてみます。
今回の記事では特にBDDについては触れません。

ソフトウェアのユニットテストにおける流派

まずはt_wadaさんのこちらのツイートから。

ツイートで登場したロンドン派デトロイト学派について調べてみます。

ロンドン学派

ロンドン学派のバイブル

デトロイト学派

  • モックをあまり使わない
  • ユニットテストの単位は1つの振る舞い
  • 動作(振る舞い)を検証する
  • 他のテストに影響しないように実行する
  • 2つ以上の動作単位を検証する場合は統合テストとして扱う

デトロイト学派のバイブル

ただ、モックの使用有無のみでロンドン学派とデトロイト学派が別れているわけではないです。
下記スライドにてテスト駆動開発の歴史についてt_wadaさんがまとめているのでこちらも併せて紹介しておきます。

speakerdeck.com

ちなみに現在私は完全にロンドン学派(モック使用派)になります。

テストの目的

さて、先程紹介したスライドを一度読んでテストについて考えて活きます。

ここでいちばん大事な抑えておかないといけないことは、

TDDのTは「テスト」の一部に過ぎない

ということです。

テスト駆動開発は自分たちのコードに自信をもって開発を続けたいプログラマのためにあります。

これはあくまで私個人の考えですが、テスト駆動開発におけるテストは全然テストじゃないと思っています。
テストというのは既存のコードの動きを可視化し、変更を検知するためにあります。

また、スライドで重要な箇所として以下のような記載があります。

  • テストは品質を上げない
  • 品質が「わかる」ようになる

テストを書くだけでは、良くはならない
品質を挙げるのは設計とプログラミング
再設計とリファクタリングをテストで支える

つまり、テスト駆動開発というのはあくまで開発者が自分自身のコードに自信を持ちながら開発を進めることのできる手法に過ぎず、テストの充実や本来の目的とは少し離れます。

先程私はロンドン学派(ユニットテストはモックを使用して他クラスとの依存を避ける流派)と言いました。
これは、ユニットテストとしてあるべき姿は他クラスとの依存を避け、クラスとしての動作を担保したテストを書くべきだという考えが根底にあります。

メリット・デメリットについても調べてみます。

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

ロンドン学派(モック化)のメリットは、

  • テストの粒度が細かく網羅的になりやすい
  • 複雑な依存関係でもすべてモック化してしまえば簡単にテストができる。
  • クラスの責務が明確になる(担保したい内容がわかりやすくなる)
  • テスト失敗時にバグの発生箇所が用意に特定できる
  • テスト実行時のDBへのI/Oの削減になり、実行時間の短縮を狙える(テストケースは増えるのでケースバイケースかも)

反対にデメリットは

  • メンテナンスコストがかかる(単純にテストコードの量が増える)

デトロイト学派(モック化しない)のメリット派

  • 統合時の動作を担保できる
  • モック化せずにテストしにくい = 設計の悪い箇所に気づきやすく、無理やりテストを通すことがなくなる
  • テストコードがアプリ単位でのドキュメントになる

何度も言いますが、私はモック派なので、上記のようなメリットを見るとモック化して書いたってデトロイト学派のメリットも普通に得られるやろ。と思っています。
そもそも両方の学派でユニットテストという言葉の定義がそもそもあやしいと思っています。
単純に見ている粒度が違うので、どの細かさでやるか、という話になると思っていて、大抵の場合論理的(テストの本来の目的の達成のため)にはモック化していいと思っています。とはいえ現実的にはデトロイト学派の言い分も十分に理解できます。費用対効果の薄い部分までモック化する意味があるのか、ということだと思っています。

さて、ここでモック化するときの注意点を上げておきます。

  • モックはカバレッジを上げるために使用するツールではない

そもそもテストの目的はカバレッジを満たすことではないです。品質を「わかる」ようにすることです。モックはいささか便利なツールになりすぎました。どんな複雑な依存関係でも簡単にテストを通すことができます。単にテストを通すことを目的とするのではなく、設計の不吉な匂いをテストによって可視化しましょう。

  • テストにおいて担保したいことを明確化する

モックに否定的な人の多くはモックが多機能な偽物であり、必要悪であることを指摘しています。
これはもちろんそのとおりなのですが、考えないといけないのはどの単位でモック化するかということです。
ソフトウェアとしての動作を担保するのであれば外部のサービスをモック化する。クラス単体としての動作を担保するのであれば外部のクラスをモック化すればいいだけです。特に後者の場合はテストにおいて担保することを明確にしておかないと意味のないテストコードになりがちなので注意が必要です。

テスト駆動開発におけるテスト

さて、ここで話をテスト駆動開発に戻します。
テストとしてはモック化すべき派の私ですが、最近はテスト駆動開発においてはモック化しないのもありかなと考えています。

これから説明するテスト駆動開発にはクリーンアーキテクチャおよびドメイン駆動開発の要素が出てきます。 用語については過去記事こちらの記事も参考にしてください。

ざっくり用語の定義をしておくと以下のようになるかなと思います。

  • UseCases,Entities層

この層ではドメインルール(ビジネスロジック)を持ちます。 一般的なRepository,Serviceといったパターンのインターフェースもこの層に属します。

  • Interface Adapters層

この層ではControllerやPresenters,Gatewaysといった外部とのやり取りのためにインターフェースを定義します。 Repository,Serviceについては実装はここに属し、インターフェースはUseCase層になります。

  • Frameworks層

この層ではフレームワークやDBといった外部ツールを持ちます。

まず大前提として、テスト駆動開発を行う際には先にユニットテストを書きます。
この段階ではコードに行わせたい振る舞いをテストコードという形で表現します。
つまり、この段階ではたいていの場合正常系を想定したユニットテストになると思います。
というよりこの段階でユースケースでの異常系で返す例外とControllerの返す例外を混同しないほうがいいので異常系は考えなくていいと思っています。UseCaseとControllerの分離については後述しますが先にクリーンアーキテクチャを理解しておくとすんなり受け入れられるかなと思います。

私がテスト駆動開発においてモック化しなくていいのはこの段階までだと思います。

現在の私のテスト駆動開発の流れは以下のようになります。

  • ビジネスロジックを定義するUseCases層のようなコア部分を中心にテストを書く
  • 最終的なコード統合時の動作をテストコードに表現したテストに先に書いてテスト駆動開発を進める
  • 異常系のようなテストについてはモック化してあとから追加する

上記の様なUseCaseを書くときにモック化しないのはありかなと思っています。ここで実現したいのは開発者の動作するという自信の担保だからです。 ただ、ユニットテストとしてはあとから追加する必要があり、その際にはモックを利用して依存を解決しましょう。

どうしてユニットテストはモック化するのか

冒頭で紹介した利点を再確認し、それ以外の利点を設計的な観点から考えてみましょう。
冒頭で紹介した利点

  • テストの粒度が細かく網羅的になりやすい
  • 複雑な依存関係でもすべてモック化してしまえば簡単にテストができる。
  • クラスの責務が明確になる(担保したい内容がわかりやすくなる)
  • テスト失敗時にバグの発生箇所が用意に特定できる
  • テスト実行時のDBへのI/Oの削減になり、実行時間の短縮を狙える(テストケースは増えるのでケースバイケースかも)

それ以外の利点はまず第一に、API単位でのテストの場合は重複するロジックが実行され続けます。
ドメイン駆動設計において境界づけられたコンテキストが同じ場合、例えば同じRepository(またはServiceやFactory)を複数のUseCaseから呼び出します。 その際に複数回Repositoryが実行され、RepositoryのバグはUseCaseのテストに影響します。
設計的には分けておくべきでしょう。
単一責任の原則をテストにも適用しましょう。テストの目的は一つに絞るようにします。

次に、テストの正確性が挙げられます。テスト対象のクラスが1つのみの場合は網羅的なテストをしやすくなります。網羅するべき内容がテスト対象を見るだけでわかるからです。
他のクラスの実装を意識してテストケースを用意する必要がなくなります。また、テストに必要な前提条件等の情報がすべてテストケース内に表現できます。
デトロイト学派(モック化しない)場合のメリットにあげた、

  • テストコードがアプリ単位でのドキュメントになる

という部分が、クラス(あるいはメソッド)単位でのドキュメントになるだけです。

ロンドン学派(モックを使用する)の費用対効果が悪いなら効果の方を上げるというアプローチ

さて、ここまでモック化するべき理由を整理してきました。 ただ、それでもモック化しない派の意見は変わらないでしょう。
モック化すると単純にテストコードの量が増えるしめんどくさいですから。そうなってテストすら書かないよりはマシです。

ここからが今回の記事で提案したい内容になります。
大抵のモック反対派の意見である費用対効果の悪さをどう克服するかについて考えていきます。

まず大前提として、テストには優先順位をつけましょう。
クリーンアーキテクチャを採用している場合、ControllerとUseCaseを明確に分けることができます。 この時点でエンドポイント単位でのテストがナンセンスなのがわかります。

理由は大抵のフレームワークの場合、

  • Controllerの実装がフレームワークに依存している
  • ControllerはリクエストをUseCaseに渡し、UseCase内で発生した例外をハンドリングする責務のみを持つ

からです。

そのため、振る舞いをテストしたい場合はUseCase以下をテストし、例外のハンドリングのみをテストしたい場合はControllerをテストするというように分離ができます。 となると必然的にビジネスロジックの集合であるUseCase単位でテストするべきです。

ここまでで最低限の費用対効果の費用の部分を削減しました。

次は効果についてです。
ユニットテストとしての効果はモック化することで十分に達成できています。

ただ、モック反対派を納得させるほど効果を最大化するためにテストを実際に動作する、腐らないドキュメントとして運用しましょう。
具体的には、詳細設計書のようなドキュメントを目指します。e2eのようなテストは明らかにユニットテストの範疇を超えているのは言うまでもないですが、モック化しない場合、ドキュメントの粒度としてもかなり大きくなってしまいます。 反対にモック化した場合はどうでしょう。 先程挙げたメリットの通り、

  • 網羅的なユニットテストができる
  • テストに必要な情報がすべてテストケース内に表現される
  • 責務が明確なユニットテストになり、バグがあれば関連箇所のみエラーが発生する

といった詳細設計書としての運用にピッタリなものになります。
経験上、詳細設計書はコードの日本語訳レベルのドキュメントに成り下がってしまうことが往々にしてあります。
この方法であれば、いつでも動かせる、そして腐らない詳細設計書がユニットテストを書き終わると自然に出来上がります。

ドキュメント化のツールについては使用する言語に合わせて選択できます。(Doxygenなど)
この際にtestフォルダと実ソースフォルダを併せてドキュメント化しておけばドキュメントとしてかなりの完成度が期待できます。
費用対効果としてはほぼ変わらない費用でドキュメントも生成できるので大幅な向上になると思っています。

まとめ

  • 大前提としてテストの取捨選択を可能にし、テストしやすさを獲得するためにクリーンアーキテクチャを採用すると楽
  • テスト駆動開発の目的は「開発」であり、モック化しないほうが開発者としては安心できる
  • テストの目的は「品質の可視化」であり、クラスごとにモックを利用して独立したクラスをテストするべき
  • 上記目的を踏まえ、正常系においてはモック化せずに動作を担保しながらテスト駆動開発をする
  • 異常系においてはモック化したほうが異常系のテストが書きやすく、効果的に動作を担保できる
  • 影響範囲を明確にした上でモック化した際の費用対効果の効果を上げるために詳細設計書としてドキュメント化する

所感

今回は勉強というよりは日々のプログラミングを通して考えていることを吐き出す回でした。
ユニットテストがないコードはすべてレガシーなコードとも言われる現代のプログラミングにおいて、費用対効果を意識したユニットテストを書くことやTDDはもはや一つのスキルと言っていいと思います。
私自身の考えとしてはかなりまとまった段階に来ているので、どんどんプロダクトに導入し、また個人開発にも活かしていこうと思います。
テストは変更容易性の向上のためにあると考えているので、モックだらけでがんじがらめにならず、モック化していないためにテストとしての質を落とさずというバランスを取ることが大事だと思います。
日頃から何を得るためのテストなのか意識してテスト駆動開発していくために、一つの考え方としてこの記事が参考になればと思います。

参考

https://zenn.dev/yum3/articles/i_unit_test_schools
https://speakerdeck.com/twada/history-of-tdd-xpjug-2018-keynote