Skip to main content

Pythonモック入門:作る前にフェイクで試そう

著者
blog hero python code purple

2018年2月10日

0 分で読めます

FugurePythonでモックを使う基本を解説するガイドへようこそ。ネットワークサービスを数多く利用するコードをテストする必要があったことと、正しくモックを使えば非常に強力であることを教えてくれたGoMockでの経験(ありがとう、Tyler)から、このガイドが生まれました。まず、モックについての考え方をお話しします。適切なモックには、適切な開発とは異なる考え方が必要だからです。開発はものを作ることですが、モックはものを偽装することです。当たり前のように思えるかもしれませんが、テストで「偽装する」という側面はモックの根幹にあり、これを理解するとテストの捉え方が大きく変わります。その後、Pythonが提供するモックツールを見ていき、最後に実例を紹介します。チートシートでPythonのセキュリティに関するコードのテストについて詳しく学びましょう。

モックは理解するのが難しいことがあります。自分が書いたコードをテストするときは、そのコードがエンドツーエンドで期待どおりに動作するかを確かめたいと思うものです。通常は、現実的な入力を与えて現実的な出力を得る、機能テストや統合テストを考えます。コードが利用する実システムにすべてアクセスし、実際のオブジェクトやAPI呼び出しを使って、それらのシステム間の連携が正しく機能することを確認します。こうしたテストは複雑なシステムがうまく連携していることを検証するうえで欠かせませんが、ユニットテストに求めるものではありません。

ユニットテストでは、コードの最外層をテストします。統合テストは必要ですが、実行する自動ユニットテストでは、システム間の連携をそこまで深くテストするべきではありません。つまり、テスト対象の関数内にあるAPI呼び出しはモックに置き換えることができ、そうするべきです。複雑なAPI呼び出しやオブジェクトの生成は、すべてモックの呼び出しやオブジェクトに置き換えましょう。これにより、不要なリソース消費を避け、テストの初期化を簡素化し、実行時間を短縮できます。外部のHTTP APIにアクセスする関数をテストする場合を考えてみましょう。正しいレスポンスを返すテストサーバーを用意する代わりに、HTTPライブラリをモックにして、すべてのHTTP呼び出しをモックの呼び出しに置き換えられます。これによってテストの複雑さと依存関係が減り、HTTPライブラリが返す値を正確に制御できます。これは、ほかの方法では難しい場合があります。

モックとは何を指すのでしょうか?

「モック」という言葉はよく使われますが、このドキュメントでは次のように定義します。

「1つ以上の関数呼び出しまたはオブジェクトを、モックの呼び出しまたはオブジェクトに置き換えること」

モック関数の呼び出しは、何も処理せず、あらかじめ定義された値をすぐに返します。モックオブジェクトの属性やメソッドも、実際のオブジェクトを生成したり処理を実行したりせず、テスト内で定義します。テストを書く人が各関数呼び出しの戻り値を定義できるため、テスト時に大きな自由度が得られます。一方で、すべてを適切に設定するための準備が必要です。

Pythonでは、unittest.mockモジュールを使ってモックを作成します。このモジュールには便利なクラスや関数がいくつも含まれており、特に重要なのはpatch関数(デコレーターおよびコンテキストマネージャーとして使用)とMagicMockクラスです。Pythonでのモックは、主にこの2つの強力な機能を使って行います。

モックとは何を指さないのでしょうか?

開発者は、ネットワークサービスやAPIを置き換える、完全に機能するローカルの「モック」オブジェクトやモジュールをよく使います。たとえば、motoライブラリはbotoのモックライブラリで、すべてのboto API呼び出しを捕捉してローカルで処理します。こうしたモックを使えば外部APIをローカルでテストできますが、それでも実際のオブジェクトを作成する必要があります。これは、このドキュメントで取り上げるモックではありません。このドキュメントでは、テスト対象の関数の制御フローを完全に管理し、エラーや例外処理を簡単にテストできるようにするため、MagicMockオブジェクトを使用する方法に焦点を当てます。

Pythonでモックを使うには?

Pythonでモックを使うには、patchを使ってAPI関数やオブジェクト生成の呼び出しを横取りします。patchが呼び出しを横取りすると、デフォルトではMagicMockオブジェクトを返します。MagicMockオブジェクトのプロパティを設定すれば、API呼び出しが任意の値を返すようにしたり、Exceptionを発生させたりできます。

全体的な手順は次のとおりです。

  1. 実際の外部APIを使う想定でテストを書きます。

  2. テスト対象の関数で、モックに置き換える必要があるAPI呼び出しを特定します。数は少ないはずです。

  3. テスト関数内でAPI呼び出しをパッチします。

  4. MagicMock オブジェクトのレスポンスを設定します。

  5. テストを実行します。

テストに合格したら完了です。合格しない場合は、テスト対象の関数にエラーがあるか、MagicMockのレスポンス設定に誤りがある可能性があります。次に、モックの作成と設定に使うツールについて詳しく見ていきましょう。

patch

import unittest 
from unittest.mock import patch

patchはデコレーターとしてテスト関数に適用でき、パッチ対象の関数名を文字列で引数に指定します。patchが対象の関数を特定できるよう、完全修飾名で指定する必要がありますが、想定とは異なる名前になる場合があります。from module import ClassA文でクラスをインポートすると、ClassAはインポート先のモジュールの名前空間に属します。

たとえば、次のようにモジュールmy_module.pyにクラスをインポートした場合:

[in my_module.py] 
from module import ClassA

@patch(my_module.ClassA)としてパッチする必要があります。@patch(module.ClassA)ではありません。これは、クラスや関数を現在の名前空間にインポートするfrom ... import ...文の仕様によるものです。

通常、patchは外部APIの呼び出しや、時間やリソースを大量に消費する関数呼び出し、オブジェクト生成に使います。1つのテストでパッチする呼び出し可能な対象は、少数にとどめてください。patchを何度も使う必要がある場合は、テストまたはテスト対象の関数をリファクタリングすることを検討しましょう。

patchデコレーターを使うと、デコレートする関数(つまりテスト関数)に位置引数が自動的に渡されます。複数の関数にパッチする場合、デコレート対象の関数に最も近いデコレーターから先に呼び出され、最初の位置引数を作成します。

@patch('module.ClassB')
@patch('module.functionA')

def test_some_func(self, mock_A, mock_B): 
...

デフォルトでは、これらの引数はMagicMockのインスタンスです。これはunittest.mockのデフォルトのモックオブジェクトです。返されたMagicMockインスタンスの属性を設定して、パッチした関数の動作を定義できます。

MagicMock

MagicMockオブジェクトは、パッチした関数やオブジェクト生成の呼び出しについて、戻り値やその他の動作を設定できるシンプルなモックインターフェースを提供します。これにより、呼び出しの動作を完全に定義でき、手間のかかる実際のオブジェクトの生成を避けられます。たとえば、HTTPライブラリのrequests.get呼び出しにパッチする場合、テスト対象の関数がAPIを呼び出したときに返されるレスポンスを定義できます。期待するレスポンスを返すテストサーバーを用意する必要はありません。

MagicMockインスタンスで最も重要な属性はreturn_valueとside_effectの2つです。どちらも、パッチした呼び出しの戻り値の動作を定義できます。

return_value

テスト関数に渡されるMagicMockインスタンスのreturn_value属性を使うと、パッチした呼び出し可能な対象が何を返すかを指定できます。多くの場合、その対象が通常返す値のモック版を返すことになります。JSON、イテラブル、値、実際のレスポンスオブジェクトのインスタンス、レスポンスオブジェクトを模したMagicMock、その他ほぼあらゆるものを指定できます。オブジェクトにパッチする場合、パッチ対象の呼び出しはオブジェクト生成の呼び出しです。そのため、MagicMockのreturn_valueにはモックオブジェクトを指定します。別のMagicMockでもかまいません。

テスト対象のコードがPythonらしく、明示的な型指定ではなくダックタイピングを使っている場合、レスポンスオブジェクトにMagicMockを使うと便利です。クラスの実際のインスタンスをわざわざ作成する代わりに、MagicMockのコンストラクターで任意の属性と値のペアを定義すれば、それらが自動的にインスタンスに適用されます。

[in test_my_module]
@patch('external_module.api_call')
def test_some_func(self, mock_api_call): 
mock_api_call.return_value = MagicMock(status_code=200,response=json.dumps({'key':'value'})) 
my_module.some_func()
[in my_module]import external_module
def some_func(): 
response = external_module.api_call()  
#normally returns a Response object, but now returns a MagicMock
#response == mock_api_call.return_value == MagicMock(status_code=200, response=json.dumps({'key':'value'}))

test_some_funcに渡される引数、つまりmock_api_callはMagicMockです。また、return_valueには別のMagicMockを設定しています。モックでは、すべてがMagicMockです。

MagicMockの仕様を指定する

MagicMockは柔軟性が高く、複雑な要件を持つクラスをすばやくモックするのに便利ですが、それが欠点になることもあります。デフォルトでは、MagicMockは、持たせたくない属性まで、あらゆる属性を持っているかのように動作します。上の例では、Responseオブジェクトの代わりにMagicMockオブジェクトを返しています。しかし、patchの呼び出しを誤り、本来Responseオブジェクトを返す関数ではなくRequestオブジェクトを返す関数にパッチしてしまったとします。返されるMagicMockは、Responseオブジェクトを模すつもりでも、Requestオブジェクトのすべての属性を持つかのように動作します。これにより、わかりにくいテストエラーや、誤ったテスト動作につながる可能性があります。

解決策は、作成時にキーワード引数specを使ってMagicMockのspecを指定することです。例:MagicMock(spec=Response)。これにより、指定したクラスに存在する属性とメソッドだけにアクセスできるMagicMockが作成されます。元のオブジェクトにない属性にアクセスしようとすると、実際のオブジェクトと同じようにAttributeErrorが発生します。MagicMockのspecに指定したクラスを基準に、アクセス可能な属性とメソッドが制限されます。

簡単な例を見てみましょう。

m = MagicMock()m.foo() 
#no error raised
# Response objects have a status_code attributem = MagicMock(spec=Response, status_code=200, response=json.dumps({‘key’:’value’}))m.foo() 
#raises AttributeErrorm.status_code #no error raised

side_effect

関数が例外を正しく処理することや、パッチ対象の関数が複数回呼び出された場合に正しく処理されることをテストしたい場合があります。side_effectを使えば実現できます。side_effectに例外を設定すると、パッチした関数の呼び出し時に、その例外がすぐに発生します。

side_effectにイテラブルを設定すると、パッチした関数が呼び出されるたびに、イテラブルから次の項目が返されます。それ以外の値をside_effectに設定すると、その値が返されます。

[in test_my_module]
@patch('external_module.api_call')

def test_some_func(self, mock_api_call): 
mock_api_call.side_effect = SomeException() 
my_module.some_func()[in my_module]def some_func(): 
try:  
	external_module.api_call() 

except SomeException:  
	print(“SomeException caught!”) 
	# this code is executed 
	except SomeOtherException:  
	print(“SomeOtherException caught!”) 
	# not executed[in test_my_module]
@patch('external_module.api_call')

def test_some_func(self, mock_api_call): 
	mock_api_call.side_effect = [0, 1] 
	my_module.some_func()[in my_module]

def some_func(): 
	rv0 = external_module.api_call() 
	# rv0 == 0 
	rv1 = external_module.api_call() 
	# rv1 == 1

assert_called_with

assert_called_withは、パッチした関数がassert_called_withの引数として指定された引数で呼び出されたことを検証します。

[inside some_func]someAPI.API_call(foo, bar='baz')[inside test_some_func]some_func()mock_api_call.assert_called_with(foo, bar='baz')

実例

この例では、Client.updateのretry関数をテストします。つまり、update内のAPI呼び出しが2回行われるため、MagicMock.side_effectを使うのに最適です。

実例のコード全体はこちらです。

import unittestfrom unittest.mock 
import patchclass TestClient(unittest.TestCase):
def setUp(self): 
	self.vars_client = VarsClient()

@patch('pyvars.vars_client.VarsClient.get')
@patch('requests.post')def test_update_retry_works_eventually(self, mock_post, mock_get): 
	mock_get.side_effect = [VarsResponse(),VarsResponse()] 
	mock_post.side_effect = [requests.ConnectionError('Test error'),  
	MagicMock(status_code=200, 
	headers={'content-type':"application/json"}, 
	text=json.dumps({'status':True})) ] 

response = self.vars_client.update('test', '0') 
self.assertEqual(response, response)

@patch('pyvars.vars_client.VarsClient.get')
@patch('requests.post') 

def test_update_retry_works_eventually(self, mock_post, mock_get):

テスト対象の関数(pyvars.vars_client.VarsClient.update)内にある2つの呼び出し、VarsClient.getとrequests.postにパッチします。2つの呼び出しにパッチするため、テスト関数には2つの引数が渡されます。ここではmock_postとmock_getという名前にしました。どちらもMagicMockオブジェクトです。デフォルトの状態では、どちらも特に何もしません。レスポンスの動作を設定する必要があります。

mock_get.side_effect = [ VarsResponse(), VarsResponse()]
mock_post.side_effect = [ requests.ConnectionError('Test error'),' MagicMock(status_code=200, headers={'content-type':"application/json"}, text=json.dumps({'status':True}))]

これは、再試行機能が最終的に正しく動作することをテストするものです。そのため、updateを複数回呼び出し、VarsClient.getとrequests.postも複数回呼び出します。

ここで、設定したいside_effectを指定します。VarsClient.getの呼び出しはすべて成功させます(このテストでは空のVarsResponseを返せば問題ありません)。requests.postの最初の呼び出しでは例外を発生させ、2回目のrequests.postの呼び出しは成功させます。このように細かく動作を制御できるのは、モックならではです。

response = self.vars_client.update('test', '0')self.assertEqual(response, response)

side_effectを設定すれば、残りのテストは簡単です。動作としては、最初のrequests.postの呼び出しが失敗するため、VarsClient.updateをラップするリトライ機能がエラーを捕捉し、2回目の呼び出しで成功するはずです。mock_getとmock_postの呼び出し履歴を確認すれば、この動作をさらに検証できます。

まとめ

モックオブジェクトを正しく使うことは、テストをできるだけ実際の状況に近づけ、徹底的に行おうとする直感に反するかもしれません。しかし、モックを使うことで、依存関係がなく、すばやく実行できる自己完結型のテストを書けるようになります。また、そうでなければテストが難しい例外処理やエッジケースも検証できます。何より重要なのは、テスト環境を整えることではなく、コードの機能そのものに注力してテストできることです。重要な点に絞ってテストすることで、テストカバレッジを高め、コードの信頼性を向上できます。それこそが、そもそもテストを行う理由です。

ドキュメントへのリンク

https://docs.python.org/3/library/unittest.mock.html

開発者のために設計されたIaCセキュリティ

Snykは、統合されたポリシー・アズ・コードエンジンにより、SDLCからクラウドでの実行時までInfrastructure as Codeを保護します。すべてのチームが安全に開発、デプロイ、運用できるよう支援します。

カテゴリー: