Pythonでのモックの概要

公開: 2022-03-11

忍耐力をテストせずにPythonでユニットテストを実行する方法

多くの場合、私たちが作成するソフトウェアは、「ダーティ」サービスとラベル付けするものと直接相互作用します。 素人の言葉で言えば、私たちのアプリケーションにとって重要であるが、その相互作用が意図しているが望ましくない副作用、つまり自律的なテスト実行のコンテキストでは望ましくないサービス。

たとえば、ソーシャルアプリを作成していて、新しい「Facebookに投稿する機能」をテストしたいが、テストスイートを実行するたびに実際にFacebookに投稿したくない場合があります。

Pythonユニットテストライブラリには、 unittestという名前のサブパッケージが含まれていunittest.mock 。依存関係として宣言する場合は、単にmockするだけです。これは、これらの望ましくない副作用をモックしてスタブ化するための非常に強力で便利な手段を提供します。

Pythonユニットテストライブラリのモックとユニットテスト

注:Python 3.3以降、 mockは標準ライブラリに新たに含まれています。 以前のディストリビューションでは、PyPI経由でダウンロード可能なMockライブラリを使用する必要があります。

システムコールとPythonモック

別の例と、この記事の残りの部分で実行する例を示すために、システムコールについて考えてみます。 これらがモックの主要な候補であることを確認するのは難しくありません。CDドライブを取り出すスクリプトを作成している場合でも、 /tmpから古いキャッシュファイルを削除するWebサーバーを作成している場合でも、TCPポートにバインドするソケットサーバーを作成している場合でも、これらはユニットテストのコンテキストで、すべての機能の望ましくない副作用を呼び出します。

開発者は、テストを実行するたびにCDトレイが開くのではなく、ライブラリがCDを取り出すためのシステム関数を正常に呼び出すことに関心があります。

開発者は、テストを実行するたびに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.MyElaborateClasstempfileモジュールをモックする必要がある場合は、各モジュールが独自のインポートを保持するため、おそらく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 。 むしろ、 UploadServiceRemovalService.rmメソッドを呼び出すことを(もちろん副作用なしで)テストするだけです。これは、前のテストケースから「正しく機能する」ことがわかっています。

これを行うには2つの方法があります。

  1. RemovalService.rmメソッド自体をモックアウトします。
  2. 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.Mockmock.MagicMockクラス

mockライブラリには、内部機能のほとんどが構築されている2つの重要なクラスmock.Mockmock.MagicMockも含まれています。 mock.Mockインスタンス、 mock.MagicMockインスタンス、またはauto-specを使用する選択肢が与えられた場合、将来の変更に備えてテストを正常に保つのに役立つため、常にauto-specの使用を優先します。 これは、 mock.Mockmock.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開発者が初期のハードルを克服し、優れたテスト済みコードを作成するのに役立つことを願っています。