Pythonでのモックの概要
公開: 2022-03-11忍耐力をテストせずにPythonでユニットテストを実行する方法
多くの場合、私たちが作成するソフトウェアは、「ダーティ」サービスとラベル付けするものと直接相互作用します。 素人の言葉で言えば、私たちのアプリケーションにとって重要であるが、その相互作用が意図しているが望ましくない副作用、つまり自律的なテスト実行のコンテキストでは望ましくないサービス。
たとえば、ソーシャルアプリを作成していて、新しい「Facebookに投稿する機能」をテストしたいが、テストスイートを実行するたびに実際にFacebookに投稿したくない場合があります。
Pythonユニットテストライブラリには、 unittest
という名前のサブパッケージが含まれていunittest.mock
。依存関係として宣言する場合は、単にmock
するだけです。これは、これらの望ましくない副作用をモックしてスタブ化するための非常に強力で便利な手段を提供します。
注:Python 3.3以降、 mock
は標準ライブラリに新たに含まれています。 以前のディストリビューションでは、PyPI経由でダウンロード可能なMockライブラリを使用する必要があります。
システムコールとPythonモック
別の例と、この記事の残りの部分で実行する例を示すために、システムコールについて考えてみます。 これらがモックの主要な候補であることを確認するのは難しくありません。CDドライブを取り出すスクリプトを作成している場合でも、 /tmp
から古いキャッシュファイルを削除するWebサーバーを作成している場合でも、TCPポートにバインドするソケットサーバーを作成している場合でも、これらはユニットテストのコンテキストで、すべての機能の望ましくない副作用を呼び出します。
開発者は、テストを実行するたびにCDトレイが実際に開いているのではなく、ライブラリがCDを取り出すためのシステム関数を(正しい引数などで)正常に呼び出すことに関心があります。 (さらに悪いことに、複数のテストが1つの単体テストの実行中にイジェクトコードを参照するため、複数回!)
同様に、単体テストを効率的かつパフォーマンスの高いものに保つことは、自動化されたテストの実行、つまりファイルシステムとネットワークアクセスからできるだけ多くの「スローコード」を排除することを意味します。
最初の例では、標準のPythonテストケースを元の形式からmock
を使用した形式にリファクタリングします。 モックを使用してテストケースを作成することで、テストがよりスマートに、より速く、ソフトウェアの動作についてより多くのことを明らかにできるようになることを示します。
簡単な削除機能
私たちは皆、時々ファイルシステムからファイルを削除する必要があるので、Pythonで関数を書いてみましょう。これにより、スクリプトでの削除が少し簡単になります。
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
明らかに、現時点でのrm
メソッドは、基盤となるos.remove
メソッド以上のものを提供していませんが、コードベースが改善され、ここに機能を追加できるようになります。
従来のテストケース、つまりモックなしで作成しましょう。
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
テストケースは非常に単純ですが、実行するたびに一時ファイルが作成されてから削除されます。 さらに、 rm
メソッドが引数をos.remove
呼び出しに適切に渡すかどうかをテストする方法がありません。 上記のテストに基づいてそれが行われると推測できますが、多くのことが望まれています。
Pythonモックを使用したリファクタリング
mock
を使用してテストケースをリファクタリングしましょう:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")
これらのリファクターにより、テストの動作方法が根本的に変わりました。 これで、インサイダーができました。これは、別のオブジェクトの機能を検証するために使用できるオブジェクトです。
潜在的なPythonモックの落とし穴
最初に目立つ必要があるのは、 mock.patch
メソッドデコレータを使用してmymodule.os
にあるオブジェクトをモックし、そのモックをテストケースメソッドに挿入することです。 mymodule.os
での参照よりも、 os
自体をモックする方が理にかなっているのではないでしょうか。
ええと、Pythonは、モジュールのインポートと管理に関しては、やや卑劣なヘビです。 実行時に、 mymodule
モジュールには独自のos
があり、モジュール内の独自のローカルスコープにインポートされます。 したがって、 os
をモックすると、 mymodule
モジュールでモックの効果を確認できません。
繰り返し続けるマントラはこれです:
アイテムの出所ではなく、使用されている場所をモックします。
myproject.app.MyElaborateClass
のtempfile
モジュールをモックする必要がある場合は、各モジュールが独自のインポートを保持するため、おそらくmyproject.app.tempfile
にモックを適用する必要があります。
その落とし穴が邪魔にならないように、あざけり続けましょう。
'rm'に検証を追加する
前に定義したrm
メソッドは非常に単純化されています。 やみくもにパスを削除しようとする前に、パスが存在し、ファイルであることを検証してもらいたいと思います。 rm
をリファクタリングして少し賢くしましょう:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
素晴らしい。 それでは、カバレッジを維持するためにテストケースを調整しましょう。
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")
私たちのテストパラダイムは完全に変わりました。 これで、副作用なしにメソッドの内部機能を検証および検証できます。
ファイル-モックパッチを使用したサービスとしての削除
これまでは、関数のモックの提供のみを扱ってきましたが、オブジェクトのメソッドや、パラメーターの送信にモックが必要な場合は扱っていません。 最初にオブジェクトメソッドについて説明しましょう。
まず、 rm
メソッドをサービスクラスにリファクタリングします。 このような単純な関数をオブジェクトにカプセル化すること自体は正当な必要性はありませんが、少なくとも、 mock
で重要な概念を示すのに役立ちます。 リファクタリングしましょう:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)
テストケースではあまり変更されていないことに気付くでしょう。
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")
これで、 RemovalService
が計画どおりに機能することがわかりました。 依存関係として宣言する別のサービスを作成しましょう。
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)
すでにRemovalServiceのテストカバレッジがあるため、 RemovalService
のテストでrm
メソッドの内部機能を検証することはしませUploadService
。 むしろ、 UploadService
がRemovalService.rm
メソッドを呼び出すことを(もちろん副作用なしで)テストするだけです。これは、前のテストケースから「正しく機能する」ことがわかっています。

これを行うには2つの方法があります。
-
RemovalService.rm
メソッド自体をモックアウトします。 -
UploadService
のコンストラクターでモックされたインスタンスを提供します。
ユニットテストでは両方の方法が重要になることが多いため、両方を確認します。
オプション1:インスタンスメソッドのモック
mock
ライブラリには、オブジェクトインスタンスのメソッドとプロパティをモックするための特別なメソッドデコレータ、 @mock.patch.object
デコレータがあります。
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")
すごい! UploadService
がインスタンスのrm
メソッドを正常に呼び出すことを検証しました。 そこに何か面白いことに気づきましたか? パッチ適用メカニズムは、実際には、テストメソッド内のすべてのRemovalService
インスタンスのrm
メソッドを置き換えました。 つまり、インスタンス自体を実際に検査できるということです。 詳細を確認したい場合は、モッキングコードのブレークポイントにドロップして、パッチ適用メカニズムがどのように機能するかをよく理解してください。
モックパッチの落とし穴:デコレータの注文
テストメソッドで複数のデコレータを使用する場合、順序は重要であり、混乱を招きます。 基本的に、デコレータをメソッドパラメータにマッピングするときは、逆方向に作業します。 この例を考えてみましょう。
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass
パラメータがデコレータの逆の順序にどのように一致しているかに注意してください。 これは、Pythonの動作方法に一部起因しています。 複数のメソッドデコレータを使用する場合、擬似コードでの実行順序は次のとおりです。
patch_sys(patch_os(patch_os_path(test_something)))
sys
へのパッチは最も外側のパッチであるため、最後に実行され、実際のテストメソッド引数の最後のパラメーターになります。 これに注意し、テストを実行するときにデバッガーを使用して、適切なパラメーターが適切な順序で挿入されていることを確認してください。
オプション2:モックインスタンスの作成
特定のインスタンスメソッドをモックする代わりに、モックしたインスタンスをそのコンストラクターでUploadService
に提供することができます。 上記のオプション1の方がはるかに正確であるため、私は上記のオプション1を好みますが、オプション2が効率的または必要な場合が多くあります。 テストをもう一度リファクタリングしてみましょう。
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")
この例では、機能にパッチを適用する必要はありませんRemovalService
クラスの自動仕様を作成し、このインスタンスをUploadService
に挿入して、機能を検証します。
mock.create_autospec
メソッドは、提供されたクラスと機能的に同等のインスタンスを作成します。 これが意味することは、実際には、返されたインスタンスが相互作用するときに、違法な方法で使用された場合に例外が発生するということです。 より具体的には、メソッドが間違った数の引数で呼び出された場合、例外が発生します。 リファクタリングが発生するため、これは非常に重要です。 ライブラリが変更されると、テストが中断し、それが予想されます。 自動仕様を使用しなくても、基盤となる実装が壊れていても、テストは合格します。
落とし穴: mock.Mock
とmock.MagicMock
クラス
mock
ライブラリには、内部機能のほとんどが構築されている2つの重要なクラスmock.Mock
とmock.MagicMock
も含まれています。 mock.Mock
インスタンス、 mock.MagicMock
インスタンス、またはauto-specを使用する選択肢が与えられた場合、将来の変更に備えてテストを正常に保つのに役立つため、常にauto-specの使用を優先します。 これは、 mock.Mock
とmock.MagicMock
が、基盤となるAPIに関係なく、すべてのメソッド呼び出しとプロパティ割り当てを受け入れるためです。 次のユースケースを検討してください。
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
これは、次のようなmock.Mock
インスタンスでテストできます。
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
このロジックは正常に見えますが、 Target.apply
メソッドを変更して、より多くのパラメーターを取得しましょう。
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
テストを再実行すると、まだ合格していることがわかります。 これは、実際のAPIに対して構築されていないためです。 これが、@ @patch
および@patch.object
デコレータで常にcreate_autospec
メソッドとautospec
パラメータを使用する必要がある理由です。
Pythonモックの例:FacebookAPI呼び出しのモック
最後に、より適切な実際のPythonモックの例を書いてみましょう。これは、冒頭で述べた、Facebookへのメッセージの投稿です。 素敵なラッパークラスとそれに対応するテストケースを作成します。
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)
これがテストケースです。これは、実際にメッセージを投稿せずにメッセージを投稿することを確認します。
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")
これまで見てきたように、Pythonでmock
を使用してよりスマートなテストを書き始めるのは本当に簡単です。
結論
Pythonのmock
ライブラリは、操作が少し混乱する場合でも、単体テストのゲームチェンジャーです。 単体テストでmock
の使用を開始するための一般的なユースケースを示しました。この記事が、Python開発者が初期のハードルを克服し、優れたテスト済みコードを作成するのに役立つことを願っています。