前置き
上記のテストモジュールの設計の記事では、詳しく触れられなかった、保守性の高いテストコードに欠かせない、設計パターンとして、GRASPというものがあります。
上記のSOLIDなどと密接に関わっていますので、テストアーキテクチャとかを考える人は、
是非とも常識にしてしまってください。
ちなみに、GRASPについては、以下の本に詳細が載っています。
古い本ですが、是非一読の価値ありです。
1. 情報エキスパート (Information Expert)
概要
ある責務は、それを果たすために必要な情報を最も多く持つクラスに割り当てるべき、
という原則です。
テストにおいては、
「あるプロダクションコードのクラスの振る舞いを検証する責任は、そのコードに対応するテストクラスが持つべき」
という考え方につながります。
メカニズム
テスト対象のオブジェクトを生成し、その状態をセットアップし、メソッドを呼び出し、
結果として変化した状態を検証する、という一連の知識はそのテストクラス内にすべて集約されます。
具体例
Orderクラスの合計金額計算ロジック(calculate_total())をテストする責任は、TestOrderクラスが持ちます。
TestOrderはOrderオブジェクトの作り方、商品の追加方法、そしてcalculate_total()の
期待値を知る専門家(エキスパート)で、Order自身の状態を検証します。
適用しないことによるデメリット
テストのロジックが複数のクラスに分散し、凝集度が低下します。
ある機能のテストがどこに書かれているのか分からなくなり、
テストの保守性が著しく低下します。
2. 高凝集 (High Cohesion)
1の情報エキスパートと、この高凝集によって、SRP:単一責任原則を実現できます。
概要
クラス内の責務が、互いに強く関連し、焦点が合っているべき 、という原則です。
テストにおいては、
「関連性の高いテストは、1つのテストクラス(またはファイル)にまとめるべき」
という意味になります。
・関連性の高いテストメソッドは、1つのテストクラスに凝集
・関連性の高い単体テストは、1つのテストコンポーネントに凝集
されているべきである。
メカニズム
一つのプロダクションクラスに対応する一つのテストクラスを作成し、
そのプロダクションクラスに関連する全てのテストケース(正常系、代替系、異常系)をそのテストクラス内に集約します。
具体例
プロダクションコードのPaymentServiceクラスに関連するテスト(カード決済、銀行振込、返金処理など)は、すべてTestPaymentServiceという単一のテストクラスにまとめます。
カード決済用のテストクラス、銀行振り込み用のテストクラス、返金処理用のテストクラス
と分離してしまうのは、
細かく分けすぎて理解容易性の低下を招きます。
適用しないことによるデメリット
テストコードが複数のファイルに散らばり、発見性が低下します。
ある機能の仕様を確認したいときに、
どのテストを見ればよいか不透明化、ドキュメント価値も失われます。
3. 疎結合 (Low Coupling)
これは、SOLIDのオープン・クローズド原則と密接に関わっています。
疎結合でないと、周辺のモジュールに連鎖的な影響を与えかねません。
概要
クラス(モジュール)間の依存度を低く保つべき、という原則です。
テストにおいては、
「テスト対象はそれが依存する他コンポーネントから可能な限り分離されるべき」
という意味になります。
メカニズム
モック、スタブ、フェイクといったテストダブルを使い、テスト対象が依存する外部コンポーネント(DB、外部APIなど)を偽のオブジェクトに置き換えます。
これにより、テスト対象のロジックだけを独立して検証できます。
具体例
UserServiceをテストする際、それが依存するEmailServiceをモックに差し替えます。
「ユーザー作成時にメールが送信されること」をテストする際、実際にメールを送信するのではなく、「EmailServiceのsendメソッドが、期待通りの引数で1回だけ呼び出されたこと」を検証します。
適用しないことによるデメリット
あるコンポーネントの変更が、それに依存する多数のテストを失敗させる波及を生みます。
するとどおでしょう? どう考えても、
テスト実行速度が遅く、不安定になり(FIRST原則違反)、失敗の原因特定も困難
になります。
※疎結合 (Low Coupling) & 高凝集 (High Cohesion)
責務を割り当てる際は、テストモジュール間の結合度を低く、テストモジュール内の凝集度を高く保つべきです。
実現方法
テストにおける疎結合は、モックやスタブを使い、テスト対象を他のコンポーネントから分離することで実現します(DIPと密接に関連)。
高凝集は、関連するテストメソッドを1つのクラスにまとめることで実現します(SRPやCCPと密接に関連)。
具体例
UserServiceのテストは、EmailServiceをモックすることで結合度を下げ、
UserServiceに関連するテスト(作成、更新、削除)をTestUserServiceクラスに集めることで凝集度を高めます。
4. 生成者 (Creator)
デザインパターンのFactoryメソッドパターンや、Builderパターンがこれに該当します。
生成の処理を自前で、各エンティティのモジュールコード内に書くと、
コードが長文になりやすくなり、可読性が一気に低下しやすくなります。
そのため、生成の責務は、関心を分離しておくことが、よく用いられる方法です。
概要
オブジェクトAを生成する責任は、Aを集約したり、密接に利用したりするオブジェクトBに持たせるべき、という原則です。
テストにおいては、
「テストで使う複雑なオブジェクトを生成する責任は、専用のファクトリやビルダーに持たせるべき」
というプラクティスとして現れます。
メカニズム
テストコード本体から、オブジェクト生成の複雑なロジックを分離します。
テストデータビルダーやテストデータファクトリといったデザインパターンを使い、一貫性のあるテストデータを簡単に生成する仕組みを構築します。
具体例
もしも、テストで管理者ユーザーと一般ユーザーが必要な場合、UserFactoryというクラスを作成します。
test_admin_can_access()ではUserFactory.create_admin()を呼び出し、
test_guest_cannot_access()ではUserFactory.create_guest()を呼び出すだけで、必要なユーザーオブジェクトを取得できます。
適用しないことによるデメリット
各テストメソッド内に、オブジェクト生成のコードが重複して散乱します。
これは、意図しない明らかなDRY原則違反。
仕様変更でオブジェクトのコンストラクタが変わった場合、
多数のテストを修正する必要があり、保守コストが増大します。
5. コントローラー (Controller)
概要
UIの背後に位置し、システムへの入力イベントを受け取って処理を他のオブジェクトに委譲する最初の窓口(Facade)を定義する原則です。
テストアーキテクチャにおいては、
「テストスイート全体の実行を管理・調整するコンポーネント」
と見なせます。
メカニズム
テストランナー(例:Pytest, JUnit)やCI/CDのパイプラインスクリプトがコントローラーの役割を果たします。
どのテストを実行するか、どの環境で実行するか、結果をどうレポートするか、といった全体のワークフローの制御をします。
具体例
CIパイプラインの設定ファイル(.github/workflows/main.ymlなど)が、プルリクが作成されたことをトリガーに、特定のバージョンのPython環境をセットアップし、pytestコマンドを実行してテストスイートを起動します。
適用しないことによるデメリット
テストの実行が場当たり的になり、一貫性と再現性が失われます。
開発者が手動で個々のテストを実行する必要があり、
CI/CDによる自動化が実現できません。(テストプロセスの品質のムラが起こる)
6. 間接化 (Indirection)
概要
2つの要素間の直接的な結合を避けるために、中間のオブジェクトを導入する原則です。
Adapterパターンなどが、これに該当します。
テストにおいては、
「テスト対象とその依存物を直接結合させず、間に抽象レイヤーを挟む」
ことを意味します。
メカニズム
依存性注入(DI)コンテナやサービスロケータがこの役割を果たします。
テスト対象は、具象クラスを直接インスタンス化するのではなく、抽象インターフェースを要求します。
テスト実行時には、この中間の仕組み(DIコンテナ)が、本物の実装の代わりにモックを注入します。
具体例
プロダクションコードがnew DatabaseRepository()とする代わりに、コンストラクタでIRepositoryインターフェースを受け取るように設計します。
テストコードでは、DIコンテナの設定を変更し、IRepositoryが要求された際にはMockRepositoryのインスタンスを返すようにします。
適用しないことによるデメリット
テスト対象コードが依存する具象クラスを直接newしている場合、テスト時にモックに差し替えることができず、
単体テストが不可能になります(低結合の原則も破られる)。
7. 多態性 (Polymorphism)
概要
オブジェクトの種類に基づいた条件分岐(if/elseやswitch)を、ポリモーフィックなメソッド呼び出しに置き換える原則です。
SOLIDのリスコフの置換原則と密接に関わっています。
テストにおいては、
「様々な実装を同じインターフェースでテストする」戦略
として適用できます。
メカニズム
共通のインターフェース(例:PaymentGateway)を定義し、そのインターフェースを実装する複数の種類の具象クラス(StripeGateway, PayPalGateway)を作成します。
そして、インターフェースに対して書かれた単一のテストスイートを、全ての実装クラスに対して実行します。
具体例
IPaymentGatewayインターフェースを定義し、それに対する一連のテスト(test_charge_succeeds, test_refund_worksなど)を作成します。
このテストスイートを、StripeGatewayのインスタンスとPayPalGatewayのインスタンスの両方で実行し、どちらも契約通りに動作することを保証します。
適用しないことによるデメリット
新しい実装が追加されるたびに、ほぼ同じ内容のテストコードをコピー&ペーストして作成する必要があり、
コードの重複と保守コストの増大を招きます。
8. 変動からの保護 (Protected Variations)
これは、SOLIDのオープン・クローズドと密接に関わっています。
この「変郷からの保護」を満たすように、変更リスクの高い部分に必要に応じて、インターフェイスを設けることで、バージョンアップなどによる、影響の範囲を小さく閉じます。
また、可逆性の担保のために、基本的には、バージョンアップの際には、新しいバージョンを追加することが前提です。
もう使わないと決めたバージョンの廃止戦略は避けられないですが
概要
不安定で変化しやすい要素の周りに、安定したインターフェースを定義して、変化の影響が他に広がらないように保護 する原則です。
テストにおいては、
「不安定な外部ライブラリやAPIを直接使わず、ラッパークラスでラップする」戦略
として適用できます。
注意点
こちらは、構造上は、7のポリモーフィズムと同じに見えます。
しかし、わたしは明確に名称を使い分けるようにしてきました。
理由は、「ポリモーフィズム」と呼んだ時には、下図のように、詳細で動作するオブジェクトは、いろんな具体の種類があるということを表現。
「変動からの保護」と呼ぶ際には、具体なオブジェクトが頻繁にバージョンアップしたり、
もしくは元のバージョンに戻されたりすることがある場合に、
下図のように、クライアントコード側がその影響を受けにくくすることを表現。
メカニズム
外部APIを直接呼び出すのではなく、そのAPIをラップする自前のMyApiClientインターフェイスを作成します。
プロダクションコードとテストコードは、この安定したMyApiClientインターフェースとのみやり取りします。
具体例
AWS S3 SDKを直接使うのではなく、StorageServiceという自前のインターフェースと
S3StorageServiceという実装クラスを作成します。
テストでは、このStorageServiceインターフェースをモックします。
将来、ストレージをS3からGoogle Cloud Storageに移行する場合でも、GCSStorageServiceを新たに作るだけで済み、既存のテストコードは一切変更する必要がありません。
適用しないことによるデメリット
外部APIの仕様変更(例: エンドポイントURLの変更、認証方法の変更)が発生した場合、
そのAPIを呼び出している全てのテストコードを修正する必要があり、
変更コストが非常に高くなります。
9. 純粋な創作物 (Pure Fabrication)
概要
情報エキスパートなどの原則に従うと不都合な場合に、ドメインモデルには存在しない、
人工的なクラス(純粋な創作物)を導入する原則です。
テストにおいては、
「テストの責務を支援するための、プロダクションコードにはないヘルパークラスを導入する」
ことを正当化します。
ただし、闇雲にこの人工的なテストクラスを作らないように注意。
メカニズム
テストデータビルダー、モックサーバー、カスタムアサーションクラスなど、テストをクリーンでDRYに保つためのユーティリティを積極的に作成します。
これらはビジネスドメインとは無関係ですが、テストの品質を向上させるためだけに存在しています。
具体例
JWTトークンを扱うAPIのテストで、毎回手動でトークンを生成するのは面倒です。
そこで、JwtTestHelperというクラスを作成し、generate_valid_token(user)やgenerate_expired_token()といったメソッドを提供します。
適用しないことによるデメリット
テストのセットアップや検証ロジックが各テストメソッドに散乱し、
可読性と保守性が低下します。
多用しすぎによるドメイン貧血なテスト
またそれ以外にも、本当は情報エキスパートに準拠して、テストメソッドを割り当てられるテストクラスがあるにもかかわらず、早く実装したいからという理由で、闇雲にこの人工物をポコポコ作ってしまうと、
ドメイン貧血症な要因を作るテストコード になってしまいます。
なので、必要最小限のみの適用にしましょう。



