Skip to main content

Pythonのユニットテストとモックを再考する

著者
blog hero python code purple

2018年7月7日

0 分で読めます

前回のブログ記事Python Mocking 101: Fake It Before You Make Itでは、Pythonにおけるモックとユニットテストの基本的な仕組みについて説明しました。この記事では、過去1年半にわたるPythonテストの経験を通じて見えてきた、より高度なソフトウェアエンジニアリングの原則を紹介します。特に、ユニットテストでモックオブジェクトをパッチするという考え方を改めて取り上げます。

外部クライアントのパッチ

この記事でいうクライアントとは、ディスクやネットワークのI/Oなど、副作用を生じさせるオブジェクトを指します。HTTP経由でメッセージを受け取り、クラウドインフラを作成して副作用を生み出し、HTTPで応答メッセージを送信するクラスCloudCreatorを考えてみましょう。

import http_client
 class CloudCreator :

def __init__(self) :

self.network_client = http_client.HTTPClient() 

CloudCreatorは、次のようにテストできます。

import unittest
import http_client from unittest.mock 
import MagicMock, patch 

class TestCloudCreator (unittest.TestCase) :  
@patch('http_client.HTTPClient')   

	def setUp (self, mock_http_client_call) :   
	self.mock_http_client = MagicMock(autospec=http_client.HTTPClient)

	# Recall that patch patches the _initialization_ call of classes 
	mock_http_client_call.return_value = self.mock_http_client   
	self.cloud_creator = CloudCreator()

patchを使うと、ネットワークの副作用を発生させずにCloudCreatorクラスをテストできます。しかし、この設計にはいくつか問題があります。CloudCreatorが多くの外部クライアントを使う場合、patchの呼び出しを何重にも重ねる必要があります。さらに、CloudCreatorとそのユニットテストはHTTPClientに強く依存しているため、ネットワーククライアントの変更が難しくなります。

Fugueのエンジニア、Josh Einhornは、patchのほかのデメリットも指摘しています。

  • これを使うと、クラス内のどこかに暗黙の依存関係が生まれます。ほかの開発者にはそれがわかりません。コンストラクターの引数にすれば、依存関係を明示的にできます。

  • 基盤となる実装をリファクタリングする際、patchを使っていると、互いに関連のない複数のユニットテストを更新する必要があります。また、より強い型付けの参照ではなくハードコードされた文字列を使うpatchの性質上(型付きの参照ならリンターやIDEで検出できます)、どのユニットテストに変更が必要なのか、必ずしも明確ではありません。

  • patchの使用はコードの臭いです。テスト対象のクラスが、1つ以上のほかの具象クラスと密結合していることを意味するからです。

  • ユニットテストでpatchに依存するコードは、ほかの言語に移植できません。コンパイルを行う静的型付け言語では、(相当な工夫をしない限り)モンキーパッチは使えません。そのようなクラスを別の言語で適切にユニットテストするには、構造をリファクタリングする必要があります。

一般に、クラスのテストに外部クライアントのpatchが大量に必要なら、リファクタリングが必要なサインです。経験豊富なソフトウェアエンジニアなら、この例が依存性逆転を適用する絶好の機会だとわかるでしょう。

依存性逆転と依存性注入

依存性逆転、とりわけこの場合の依存性注入は、ソフトウェアエンジニアリングの世界ではよく知られたテーマです。初めて耳にする方のために説明すると、依存性注入とは、クラスや関数が依存する外部クライアントを自ら生成するのではなく、外部から渡してもらうという考え方です。これにより、渡されるクライアントに応じて、コードをさまざまなコンテキストで動作させられます。

この例では、CloudCreatorの中心的な機能であるクラウドインフラの作成は、メッセージの送受信方法に依存しません。したがって、ネットワークI/Oをハードコードするのではなく、実行時に注入されるクライアントに任せる形でクラスを記述するのが合理的です(このコードではPythonの型ヒント構文を使っています)。

class CloudCreator :   
def __init__(self, network_client: NetworkClient) : 
self.network_client = network_client 

このカプセル化により、ネットワークI/OがNetworkClientで定義された事前指定のインターフェース(NetworkClient.recv()やNetworkClient.send(data)など)に準拠している限り、HTTPクライアント、TCP/IPクライアント、ZMQクライアント、SQS/SNSクライアントのいずれでもクラスを利用できます。これはプロセスを大幅に簡素化します。また、鋭い読者なら、クライアントインターフェースの仕様がきわめて重要になることにも気づくでしょうが、それはまた別の記事で取り上げます。

依存性注入の主な利点の1つは、ユニットテスト時にモックオブジェクトを簡単に渡せることです。これで、次のようにテストを準備できます。

import unittest from unittest.mock 
import MagicMock 
class TestCloudCreator (unittest.TestCase) :   

def setUp (self) :   
self.mock_network_client = MagicMock(autospec=NetworkClient)  
self.cloud_creator = CloudCreator(self.mock_network_client)

NetworkClientインターフェースに準拠するモックオブジェクトを作るため、MagicMockのautospec引数を使って、ユニットテスト用のモックネットワーククライアントを作成します。

このシンプルな例では、patchによって、さらに柔軟性の低いユニットテストを作成し、柔軟性に欠ける設計の問題を覆い隠していました。先ほど述べたように、数回を超えてパッチする必要があるなら、リファクタリングを検討すべきサインです。ただし、patchは今でも便利です。たとえば、time.time()など、副作用のないライブラリ関数の呼び出しをパッチする場合に使えます。

依存性注入をさらに活用する

依存性注入は便利ですが、クラスが多数のクライアントを必要とする場合はどうすればよいでしょうか。パラメーターを増やして、それぞれを個別に注入できます。

class CloudCreator :     
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None:
 AccountsClient, 
 log_writer=None: LogWriter, 
 health_check=None: HealthCheckClient, 
 metrics=None: MetricsWriter, 
 database_client=None: DatabaseClient) :   

 self.network_client = network_client 
 self.authz_client = authz_client 
 self.accts_client = accts_client 
 self.log_writer = log_writer
 self.health_check = health_check 
 self.metrics = metrics 
 self.database_client = database_client

デフォルト値を設定すれば、クライアントを選択的に初期化でき、ユニットテストが簡単になります(詳しくは後述します)。しかし、この方法は、特に統合テストを行う際には、依然として扱いにくいものです。メッセージハンドラーを変更し、その後、サーバーと正しく通信できるかテストしたい場合を考えてみましょう。CloudCreatorを初期化するには、クライアントオブジェクトを作成して初期化する面倒な作業が数多く必要です。Pythonの強みの1つは対話型インタープリターで、反復的な開発プロセスを実現できます。REPLを手軽に使える状態にしておくことは、開発者にとって大きな助けになります。コアクラスの小さな変更をテストするだけなのに、開発者が十数個のクライアントオブジェクトを作成・初期化しなければならないと、フラストレーションにつながります。

解決策の1つは、クライアントの作成を別のオブジェクトにカプセル化することです。コンストラクターで、処理の順序や依存関係を定義することもできます。

class CloudCreatorServices :   
def __init__() : 

 self.network_client = HTTPClient()   
 self.database_client = SQLClient()     
 self.authz_client = AuthzClient(self.database_client)   
 self.accts_client = AcctsClient(self.database_client)   
 self.log_writer = LogWriter()   
 self.health_check = HealthCheck()   
 self.metrics = Metrics() 

def main() :  
 services = CloudCreatorServices()  
 cc = CloudCreator(services.network_client, services.authz_client, services.accts_client, services.log_writer, services.health_check, services.metrics, services.database_client)

CloudCreatorは引き続き、必要なサービスへの明示的な参照を使って初期化される点に注目してください。これにより、将来の開発者もCloudCreatorに必要なサービスを簡単に把握できます。CloudCreatorのコンストラクターがCloudCreatorServicesオブジェクトだけを受け取る設計も考えられます。

class CloudCreator : 

def __init__(self, services: CloudCreatorServices) :   
	self.services = services 

しかし、この設計ではCloudCreatorがCloudCreatorServicesの特定の実装に依存し、CloudCreatorが必要とするサービスだけが含まれることになります。CloudCreatorServicesを複数のクラス向けのサービスを作成するように汎用化すると、汎用のServiceクラスを使うクラスは、すべてのサービスを必要とするものと呼び出し側が想定しなければなりません。

残念ながら、この単純な実装では、直接依存性を注入する方法の柔軟性が失われます。一歩進んで一歩後退です。これは簡単に解決できます。

class CloudCreatorServices :   
def __init__(self, network_client: NetworkClient, authz_client: AuthzClient, accts_client: AccountsClient, log_writer: LogWriter, health_check: HealthCheckClient, metrics: MetricsWriter, database_client: DatabaseClient) :

	self.network_client = network_client   
	self.authz_client = authz_client   
	self.accts_client = accts_client   
	self.log_writer = log_writer   
	self.health_check = health_check   
	self.metrics = metrics   
	self.database_client = database_client

ここまでで行ったのは、複雑さを別の場所に移しただけです。開発者は依然として、すべてのクライアントを初期化しなければなりません。作業を楽にするための仕組みは何も提供できていません。CloudCreatorServicesに複雑な初期化処理を組み込めば、それを実現できます。

class CloudCreatorServices :   
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self.database_client = database_client or self._get_database_client()   
	self.network_client = network_client or self._get_network_client()   
	self.authz_client = authz_client or self._get_authz_client(self.database_client)   
	self.accts_client = accts_client or self._get_accts_client(self.database_client)   
	self.log_writer = log_writer or self._get_log_writer()   
	self.health_check = health_check or self._get_health_check()   
	self.metrics = metrics or self._get_metrics() 

これで、初期化メソッドの中にクライアントの作成を隠せました。よい解決策に見えますが、よく考えるとデメリットもあります。CloudCreatorServicesを初期化すると、使わないとわかっているクライアントまですべて作成されます。クライアントサービスの1つに問題がありタイムアウトするものの、ほかの機能はテストしたい場合、どうすればよいでしょうか。さらに柔軟にできないでしょうか。

ゲッターメソッドを使えば、初期化の不変条件を変えられます。

class CloudCreatorServices :  
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self._database_client = database_client   
	self._network_client = network_client   
	self._authz_client = authz_client  
	self._accts_client = accts_client   
	self._log_writer = log_writer   
	self._health_check = health_check  
	self._metrics = metrics  

def get_database_client (self) DatabaseClient :   

	if not self._database_client:   
	self._database_client = DatabaseClient()   
	return self._database_client  

	def get_authz_client (self) AuthzClient :   

	if not self._authz_client:   
	self.authz_client = AuthzClient(self.get_database_client())   
	return self.authz_client 
	...

この方法では遅延読み込みを実現でき、必要なときだけクライアントを初期化しつつ、必要に応じてクライアントを差し替えることもできます。しかし、ゲッターメソッドはPythonらしい書き方とはいえません。よりPythonらしい方法を見つけるために活用できる言語機能はないでしょうか。

@property

探している機能は@propertyです。

class CloudCreatorServices :   
def __init__(self, network_client=None: NetworkClient, authz_client=None: AuthzClient, accts_client=None: AccountsClient, log_writer=None: LogWriter, health_check=None: HealthCheckClient, metrics=None: MetricsWriter, database_client=None: DatabaseClient) :   

	self._database_client = database_client   
	self._network_client = network_client   
	self._authz_client = authz_client   
	self._accts_client = accts_client   
	self._log_writer = log_writer   
	self._health_check = health_check   
	self._metrics = metrics @property   

def database_client (self)  DatabaseClient :   

	if not self._database_client:    
	self._database_client = DatabaseClient()   
	return self._database_client  

def authz_client (self)  AuthzClient :   

	if not self._authz_client:    
	self.authz_client = AuthzClient(self.database_client)   
	return self.authz_client  
	...

これは前述のゲッターメソッドを使う方法に似ていますが、get_を削除し、@propertyデコレーターを追加しています。@propertyを使うと、ゲッターメソッドをプロパティにできます。プロパティは、かっこを付けずにCloudCreatorServices.database_clientのように直接アクセスできます。さらに@propertyを使えば、将来的にセッターを追加できます。たとえば、プロパティのセッター関数に@.setterを付けます。

class CloudCreatorServices :  
... 
@property   
def database_client (self)  DatabaseClient :   

if not self._database_client:    
self._database_client = DatabaseClient()   
return self._database_client @database_client.setter   

def _set_database_client (self, database_client: DatabaseClient) :   
self.database_client = database_client

database_clientに値を代入すると、セッターが透過的に呼び出されます。

services = CloudCreatorServices() 
# calls _set_database_client services.database_client = DatabaseClient()

@propertyを使うことで、インスタンス属性に直接アクセスするというPythonの標準的な書き方を保ちながら、ゲッターやセッターで属性アクセスをラップする柔軟性も得られます。

依存性注入を活用したモック

依存性逆転の利点の1つは、ユニットテストが大幅に簡単になることです。CloudCreatorの初期化引数にはデフォルト値があるため、特定のテストに必要なクライアントサービスオブジェクトだけを選んでモックできます。

import unittest from unittest.mock 
import MagicMock class TestCloudCreator (unittest.TestCase) :   

def test_network_write_method (self) :   
	self.mock_network_client = MagicMock(autospec=NetworkClient)   
	self.cloud_creator = CloudCreator(network_client=self.mock_network_client)   
	...

ほかのクライアントサービスは初期化されないため、ネットワークコードの処理が担当範囲外のオブジェクトに触れていないかを簡単に確認できます。通常、そうした動作は何かがおかしいサインです。もちろん、網羅的なユニットテストにはすべてのサービスのモックが必要ですが、簡単に追加できます。

依存性逆転を使うことで、ユニットテストでpatchを使う必要がなくなり、開発者は統合テストに役立つ、時間を節約できる強力なツールを使えるようになりました。

カテゴリー:

続きを読む

Blog

Stadium Summer:Snyk Connect Fan Zone Tour

SnykのFan Zone Tourでは、8都市と3回のオンラインセッションで、AIセキュリティのワークショップやネットワーキング、楽しい競技を開催しました。参加者はスキルを磨き、アイデアを共有し、共に成長しました。

Blog

Snyk VulnBench JS 1.0:LLMは同じバグを2回見つけられるか?

Snyk VulnBench JS 1.0:300回の反復スキャンで、LLMのセキュリティ検出結果は実行ごとに異なる一方、SASTとモデルはそれぞれ異なる脆弱性の見落としを検出することが明らかに。

feature insights context
Blog

お客様と築くAIセキュリティ:Evoのデザインパートナープログラムから得た5つの教訓

SnykのEvoデザインパートナープログラムから得た5つの重要な教訓をご紹介します。AIの検出、リスクインテリジェンス、ポリシーの自動化によって、チームが生成AIを保護し、拡大するAI利用を大規模に統制する方法をご覧ください。