Pythonのユニットテストとモックを再考する
2018年7月7日
0 分で読めます編集者注
このブログ記事は、もともとfugue.coに掲載されていました。Fugueは2022年にSnykの一員となり、Snyk IaCの重要な構成要素となっています。
前回のブログ記事Python Mocking 101: Fake It Before You Make Itでは、Pythonにおけるモックとユニットテストの基本的な仕組みについて説明しました。この記事では、過去1年半にわたるPythonテストの経験を通じて見えてきた、より高度なソフトウェアエンジニアリングの原則を紹介します。特に、ユニットテストでモックオブジェクトをパッチするという考え方を改めて取り上げます。
外部クライアントのパッチ
この記事でいうクライアントとは、ディスクやネットワークのI/Oなど、副作用を生じさせるオブジェクトを指します。HTTP経由でメッセージを受け取り、クラウドインフラを作成して副作用を生み出し、HTTPで応答メッセージを送信するクラスCloudCreatorを考えてみましょう。
CloudCreatorは、次のようにテストできます。
patchを使うと、ネットワークの副作用を発生させずにCloudCreatorクラスをテストできます。しかし、この設計にはいくつか問題があります。CloudCreatorが多くの外部クライアントを使う場合、patchの呼び出しを何重にも重ねる必要があります。さらに、CloudCreatorとそのユニットテストはHTTPClientに強く依存しているため、ネットワーククライアントの変更が難しくなります。
Fugueのエンジニア、Josh Einhornは、patchのほかのデメリットも指摘しています。
これを使うと、クラス内のどこかに暗黙の依存関係が生まれます。ほかの開発者にはそれがわかりません。コンストラクターの引数にすれば、依存関係を明示的にできます。
基盤となる実装をリファクタリングする際、
patchを使っていると、互いに関連のない複数のユニットテストを更新する必要があります。また、より強い型付けの参照ではなくハードコードされた文字列を使うpatchの性質上(型付きの参照ならリンターやIDEで検出できます)、どのユニットテストに変更が必要なのか、必ずしも明確ではありません。patchの使用はコードの臭いです。テスト対象のクラスが、1つ以上のほかの具象クラスと密結合していることを意味するからです。ユニットテストで
patchに依存するコードは、ほかの言語に移植できません。コンパイルを行う静的型付け言語では、(相当な工夫をしない限り)モンキーパッチは使えません。そのようなクラスを別の言語で適切にユニットテストするには、構造をリファクタリングする必要があります。
一般に、クラスのテストに外部クライアントのpatchが大量に必要なら、リファクタリングが必要なサインです。経験豊富なソフトウェアエンジニアなら、この例が依存性逆転を適用する絶好の機会だとわかるでしょう。
依存性逆転と依存性注入
依存性逆転、とりわけこの場合の依存性注入は、ソフトウェアエンジニアリングの世界ではよく知られたテーマです。初めて耳にする方のために説明すると、依存性注入とは、クラスや関数が依存する外部クライアントを自ら生成するのではなく、外部から渡してもらうという考え方です。これにより、渡されるクライアントに応じて、コードをさまざまなコンテキストで動作させられます。
この例では、CloudCreatorの中心的な機能であるクラウドインフラの作成は、メッセージの送受信方法に依存しません。したがって、ネットワークI/Oをハードコードするのではなく、実行時に注入されるクライアントに任せる形でクラスを記述するのが合理的です(このコードではPythonの型ヒント構文を使っています)。
このカプセル化により、ネットワークI/OがNetworkClientで定義された事前指定のインターフェース(NetworkClient.recv()やNetworkClient.send(data)など)に準拠している限り、HTTPクライアント、TCP/IPクライアント、ZMQクライアント、SQS/SNSクライアントのいずれでもクラスを利用できます。これはプロセスを大幅に簡素化します。また、鋭い読者なら、クライアントインターフェースの仕様がきわめて重要になることにも気づくでしょうが、それはまた別の記事で取り上げます。
依存性注入の主な利点の1つは、ユニットテスト時にモックオブジェクトを簡単に渡せることです。これで、次のようにテストを準備できます。
NetworkClientインターフェースに準拠するモックオブジェクトを作るため、MagicMockのautospec引数を使って、ユニットテスト用のモックネットワーククライアントを作成します。
このシンプルな例では、patchによって、さらに柔軟性の低いユニットテストを作成し、柔軟性に欠ける設計の問題を覆い隠していました。先ほど述べたように、数回を超えてパッチする必要があるなら、リファクタリングを検討すべきサインです。ただし、patchは今でも便利です。たとえば、time.time()など、副作用のないライブラリ関数の呼び出しをパッチする場合に使えます。
依存性注入をさらに活用する
依存性注入は便利ですが、クラスが多数のクライアントを必要とする場合はどうすればよいでしょうか。パラメーターを増やして、それぞれを個別に注入できます。
デフォルト値を設定すれば、クライアントを選択的に初期化でき、ユニットテストが簡単になります(詳しくは後述します)。しかし、この方法は、特に統合テストを行う際には、依然として扱いにくいものです。メッセージハンドラーを変更し、その後、サーバーと正しく通信できるかテストしたい場合を考えてみましょう。CloudCreatorを初期化するには、クライアントオブジェクトを作成して初期化する面倒な作業が数多く必要です。Pythonの強みの1つは対話型インタープリターで、反復的な開発プロセスを実現できます。REPLを手軽に使える状態にしておくことは、開発者にとって大きな助けになります。コアクラスの小さな変更をテストするだけなのに、開発者が十数個のクライアントオブジェクトを作成・初期化しなければならないと、フラストレーションにつながります。
解決策の1つは、クライアントの作成を別のオブジェクトにカプセル化することです。コンストラクターで、処理の順序や依存関係を定義することもできます。
CloudCreatorは引き続き、必要なサービスへの明示的な参照を使って初期化される点に注目してください。これにより、将来の開発者もCloudCreatorに必要なサービスを簡単に把握できます。CloudCreatorのコンストラクターがCloudCreatorServicesオブジェクトだけを受け取る設計も考えられます。
しかし、この設計ではCloudCreatorがCloudCreatorServicesの特定の実装に依存し、CloudCreatorが必要とするサービスだけが含まれることになります。CloudCreatorServicesを複数のクラス向けのサービスを作成するように汎用化すると、汎用のServiceクラスを使うクラスは、すべてのサービスを必要とするものと呼び出し側が想定しなければなりません。
残念ながら、この単純な実装では、直接依存性を注入する方法の柔軟性が失われます。一歩進んで一歩後退です。これは簡単に解決できます。
ここまでで行ったのは、複雑さを別の場所に移しただけです。開発者は依然として、すべてのクライアントを初期化しなければなりません。作業を楽にするための仕組みは何も提供できていません。CloudCreatorServicesに複雑な初期化処理を組み込めば、それを実現できます。
これで、初期化メソッドの中にクライアントの作成を隠せました。よい解決策に見えますが、よく考えるとデメリットもあります。CloudCreatorServicesを初期化すると、使わないとわかっているクライアントまですべて作成されます。クライアントサービスの1つに問題がありタイムアウトするものの、ほかの機能はテストしたい場合、どうすればよいでしょうか。さらに柔軟にできないでしょうか。
ゲッターメソッドを使えば、初期化の不変条件を変えられます。
この方法では遅延読み込みを実現でき、必要なときだけクライアントを初期化しつつ、必要に応じてクライアントを差し替えることもできます。しかし、ゲッターメソッドはPythonらしい書き方とはいえません。よりPythonらしい方法を見つけるために活用できる言語機能はないでしょうか。
@property
探している機能は@propertyです。
これは前述のゲッターメソッドを使う方法に似ていますが、get_を削除し、@propertyデコレーターを追加しています。@propertyを使うと、ゲッターメソッドをプロパティにできます。プロパティは、かっこを付けずにCloudCreatorServices.database_clientのように直接アクセスできます。さらに@propertyを使えば、将来的にセッターを追加できます。たとえば、プロパティのセッター関数に@.setterを付けます。
database_clientに値を代入すると、セッターが透過的に呼び出されます。
@propertyを使うことで、インスタンス属性に直接アクセスするというPythonの標準的な書き方を保ちながら、ゲッターやセッターで属性アクセスをラップする柔軟性も得られます。
依存性注入を活用したモック
依存性逆転の利点の1つは、ユニットテストが大幅に簡単になることです。CloudCreatorの初期化引数にはデフォルト値があるため、特定のテストに必要なクライアントサービスオブジェクトだけを選んでモックできます。
ほかのクライアントサービスは初期化されないため、ネットワークコードの処理が担当範囲外のオブジェクトに触れていないかを簡単に確認できます。通常、そうした動作は何かがおかしいサインです。もちろん、網羅的なユニットテストにはすべてのサービスのモックが必要ですが、簡単に追加できます。
依存性逆転を使うことで、ユニットテストでpatchを使う必要がなくなり、開発者は統合テストに役立つ、時間を節約できる強力なツールを使えるようになりました。



