Pythonユニットテストのモック徹底解説:仕組みから実践的な使い方まで
はじめに
目的
unittest, pytest でモックするときに使う、 unittest.mock
実装を確認して、どのようにモックを実現しているか理解し、ベストプラクティスを見つける
背景
- Python でユニットテストを書く時にモックオブジェクトをふんわり使うのをやめたい
- 自信を持ってモックしたい
以上の理由から、腹落ちするまで調べることにしました。
知りたい疑問
- Pythonにおける "モックオブジェクト" とは何か?
- どのようにしてモックを実現しているのか?
- モックオブジェクト使用のベストプラクティスは?
対象読者
- unittest, pytest で mock を使ったことがあるが原理がわからない人
- unittest, pytest で mock を使ったユニットテストを書きたいが書き方がよくわからない人
この記事を読むとわかること
Q. Pythonにおける "モックオブジェクト" とは何か?
A. テストで用いられる実際のオブジェクトの動作を模倣する擬似オブジェクト
Q. どのようにしてモックを実現しているのか?
A. モック対象のクラス属性を一時的にモックオブジェクトに書き換える処理をしている
Q. モックオブジェクト使用のベストプラクティスは?
A. unitest.mockの挙動を理解した上で、pytest-mock を使う。必要最小限のモック化を行い、再利用する場合はpytest.fixtureを活用する
Python における "モックオブジェクト" とは何か?
定義と特徴
一般に"モックオブジェクト"とは、実際のオブジェクトの動作を模倣する擬似オブジェクトで、主にユニットテストを書く時に一部の処理をテスト用の処理に置き換える場合に使われます。
モックオブジェクトの特徴は以下の通りです。多くの言語のテストケースフレームワークでこれらは共通ですね。
- 柔軟に振る舞いを設定できる:擬似メソッドの戻り値や例外(エラー)を自由に設定できる
- 状態を追跡できる:擬似メソッドの呼び出し回数や引数など記録できる
また、Pythonにおいては動的なモックオブジェクト属性生成もサポートしています。
- 動的に属性を生成する:存在しない属性にアクセスしても新しいモックオブジェクトを返してくれる
モックオブジェクトを使ったテストの例
この記事ではあくまで原理の説明をしたいため、詳細な使い方に踏み込みませんが、話の都合上、簡単なテストの書き方を説明します。
準備:モック対象のクラスメソッド
以下の MyClass
クラスの my_method
メソッドを、ユニットテスト時には実行せずモック化し、設定した値を返すようにしたい場合を考えます。
my_method
の具体的な内容はここではなんでも良いですが、例えば、重い処理、例えばRDBとの通信や外部APIとの通信などが含まれており、ユニットテスト時には実行したくないというケースを想像してもらえればよいです。
# mymodule/my_classes.py
class MyClass:
def __init__(self):
pass
def my_method(self) -> str:
# 何やら外部と通信したり、ユニットテスト時には実行したくない処理がある
# ...
return "original_value"
my_method
を呼び出す側のコードはテストしたいけど、my_method
自体は実行したくない、というケースでモック化が役に立ちます。
モック化されていないテスト
例えば、MyClass
を使ったテストは以下のように書くことができます。
# tests/test_my_class.py
def test_my_class_my_method():
from mymodule.my_classes import MyClass
my_class = MyClass()
assert my_class.my_method() == 'mocked'
# pytest tests/test_my_class.py などで実行
ただ、これだと特にモックする処理を入れていないため、 'original_value' != 'mocked'
となり、assert に失敗するでしょう。
では、実際にモックを導入してみます。
unitest.mockでモック化したテスト
with patch(...)
)
コンテキストマネージャーを使う場合 (patch
で返ってきた m
とやらに m.return_value
を設定すると、 my_method
が mocked
を返してくれます。
from unittest.mock import patch
def test_my_method_context_manager():
with patch('mymodule.my_classes.MyClass.my_method') as m:
m.return_value = 'mocked'
from mymodule.my_classes import MyClass
my_class = MyClass()
assert my_class.my_method() == 'mocked'
@patch(...)
)
デコレータを使う場合 (ユニットテストの関数にデコレータでラップすることで、モックメソッドとして引数で受け取ることができます。
from unittest.mock import Mock, patch
@patch('mymodule.my_classes.MyClass.my_method')
def test_my_method_decorator(mock_method: Mock):
mock_method.return_value = 'mocked'
from mymodule.my_classes import MyClass
my_class = MyClass()
assert my_class.my_method() == 'mocked'
pytest-mockでモック化したテスト
後述するので省略します。
モックの実現方法:内部実装の解説
どのようにしてモックを実現しているのか?
さて、使い方はなんとなくわかったとしても、なんで動いてるのかよくわかりませんよね。
これらのモックは何をやってるのでしょうか?
結論としては、 モック対象のクラス属性を一時的にモックオブジェクトに書き換える処理をしています。
処理の流れがそれなりに複雑なので、それを順を追って説明します。
全体の流れを図で表すと次のとおりです。
with patch(...)
使ったり、 @patch(...)
使ったり、いろんな記法がありますね。
これらの挙動を把握するために、 with patch(...) as m
でどのような処理をしているか調べると手っ取り早いです。
これには、以下の二つを理解する必要があります。
-
patch(...)
の処理 -
with
句での処理
patch の仕組み
patch
の実装を読むと、次のようになっています。
# (簡略版)
def patch(target):
target_class, target_attribute = self.get_target(target)
return _patch(
target_class,
target_attribute,
)
patch
実装は以下のことを行います。
- モックターゲット名を受け取り、クラスと属性に分解する
- 入力
- モックターゲット名:
mymodule.my_classes.MyClass.my_method
- モックターゲット名:
- 出力
- クラス:
mymodule.my_classes.MyClass
- 属性:
my_method
- クラス:
- 入力
- ↑の出力を用いて、
_patch
クラスインスタンスを生成している
__enter__と__exit__の役割
_patch
クラスインスタンスはどのような働きをするのでしょうか。
この理解のために、 with
句の仕様について知っておく必要があります。
with A() as a
とは何か
そもそもの前提: with
句は Python の言語仕様で実現されている記法であり、オブジェクトのライフサイクルを制御することができます。より正確には、オブジェクトのライフサイクルを意識したコードを書いてもらうための言語としての文法としての補助といった感じでしょうか。
with A() as a
のような独自のクラス A
に対し、このスコープの制限を行うには、 A
に以下のクラスメソッドを実装する必要があります。
-
__enter__(self)__
: 最初に実行される -
__exit__(self, ...)
: 終了時に実行される
with A() as a:
a.hello()
と言う処理は、ざっくり書くと、以下の処理と同じことをやっています。
_a = A()
try:
a = _a.__enter__()
a.hello()
finally:
_a.__exit__()
要は、クラスメソッドとして入口と出口を実装しておけば、 with
句を使うと入口と出口を勝手に適切なタイミングで実行してくれるわけですね。
クラス属性の置き換え
さて、今知りたいのは、 patch(...)
で返ってくる _patch
クラスインスタンスの __enter__
と __exit__
の処理です。
with patch('mymodule.my_classes.MyClass.my_method') as m:
m.return_value = 'mocked'
# (略)
先ほどの例を当てはめると、この実装は次のような処理をしているのと大体同じことです。
_p = patch('mymodule.my_classes.MyClass.my_method')
try:
m = _p.__enter__()
m.return_value = 'mocked'
# 実際のテストコード
finally:
m.__exit__()
with
の最初で実行される __enter__
での処理と、最後に実行される __exit__
での処理を見れば、モック化についてより詳細を知ることができます。
詳細知りたい人は内部実装を見てもらうと良いですが、複雑なので、簡略にしたものを以下に示します。
# `_patch` は `patch` メソッドが返すクラス (簡略版)
class _patch(...):
def __init__(self, target_class, target_attribute):
self.target_class = target_class
self.target_attribute = target_attribute
def __enter__(self):
# 一時的にオリジナルを退避
self.temp_original = getattr(self.target_class, self.target_attribute)
# モックオブジェクトを作成
m = Mock()
# モックオブジェクトをターゲットクラスの属性にセット!
setattr(self.target_class, self.target_attribute, m)
return m
def __exit__(self):
# オリジナルの属性を復活
setattr(self.target_class, self.target_attribute, self.temp_original)
del self.temp_original
setattr
では、 setattr(C, a, b)
とすると、クラス C
の属性 a
を b
にセットすることができます。
つまり、以下がポイントです。
-
__enter__
で対象クラスのオリジナル属性をtemp_original
に退避し、その属性をモックオブジェクトに差し替えている -
with patch(...) as m
のm
には↑で作ったモックオブジェクトが渡される -
__exit__
で対象クラスのオリジナル属性を元のクラス属性に戻す
割と強引なやり方ですが、このようにすれば確かにモック化した属性を m
へのアクセスを通じて扱うことができるようになるわけです。
モック化の流れを振り返る
もう一度振り返ってみます。(try
はここでは省略します。)
# モック対象のクラス名と属性を取得する
_p = patch('mymodule.my_classes.MyClass.my_method')
# ここでクラス属性の置き換えを行い、その新しい属性の参照が `m` になる
m = _p.__enter__()
# 属性 `m` を通じて、クラス属性の振る舞いを設定できる
m.return_value = 'mocked'
# 実際のテストコード (`my_method` を呼び出すと `m` を呼び出すことになる)
# ...
# オリジナルの属性に戻す
m.__exit__()
図にすると
補足: メソッド名からクラスと属性を取得する
patch(...)
実装 で呼び出されている _get_targetメソッドの中で、クラス名の解決を行っている箇所があります。
内部では、クラス名と属性名の split を行い、クラス名を pkgutil.resolve_name
関数に渡すことでクラスへの参照を取得しています。
from pkgutil import resolve_name
target = 'mymodule.my_classes.MyClass'
target_class, target_attribute = target.rsplit('.', 1)
print(resolve_name('mymodule.my_classes.MyClass'))
# -> <class 'mymodule.my_classes.MyClass'>
逆に言えば、このような resolve_name
で解決できないパス名を渡してしまうと、モックにできないので気をつけましょう。
unittest.mockとpytest_mockの違い
Pythonにおけるユニットテストは unittest
と pytest
あたりが主な選択肢だと思います。
pytest-mock
は unittest.mock
のラッパーなので、内部としてやってることは同じですが、 pytest-mock
の方がシンプルに書けるため、pytestを使っている場合、pytest-mockを使うのが良いでしょう。
unitest.mockの特徴
- Python標準ライブラリの一部
- 多機能
pytest-mockの利点
- pytestに統合されている
- pytest fixtureを通じてアクセスできる
- シンプルなインターフェイス
pytest-mock
の使い方
pytest
に特化した pytest-mock
の使い方を見ていきましょう。
MockerFixture
テスト内部でモックオブジェクトを定義する
import pytest
from pytest_mock import MockerFixture
def test_my_method_by_mocker_patch(
mocker: MockerFixture,
):
other_mock = mocker.patch("mymodule.other_classes.OtherClass.other_method")
other_mock.return_value = "mocked"
from mymodule.other_classes import OtherClass
other_class = OtherClass()
assert other_class.other_method() == "mocked"
fixtureとしてモックオブジェクトを定義する
pytest.fixture
でモックオブジェクトをfixtureを使うようにできます。
複数のテストケースで同じ性質のモックオブジェクトを利用したい場合に便利です。
from unittest.mock import Mock
import pytest
from pytest_mock import MockerFixture
@pytest.fixture
def mock_method(mocker: MockerFixture):
return mocker.patch("mymodule.my_classes.MyClass.my_method")
def test_my_method_by_pytest_fixture(
mock_method: Mock,
):
mock_method.return_value = "mocked"
from mymodule.my_classes import MyClass
my_class = MyClass()
assert my_class.my_method() == "mocked"
複数のモックオブジェクトを使う場合
複数のモックオブジェクトを使う場合にも、テストの引数にmocker
やfixtureを追加するだけで済みます。
from unittest.mock import Mock
import pytest
from pytest_mock import MockerFixture
@pytest.fixture
def mock_method(mocker: MockerFixture):
return mocker.patch("mymodule.my_classes.MyClass.my_method")
@pytest.fixture
def mock_method_2(mocker: MockerFixture):
return mocker.patch("mymodule.my_classes.MyClass.my_method_2")
def test_my_method_by_multiple(
# fixture
mock_method: Mock,
mock_method_2: Mock,
# 新規作成用
mocker: MockerFixture,
):
# モックオブジェクト新規作成
other_mock = mocker.patch("mymodule.other_classes.OtherClass.other_method")
other_mock.return_value = "mocked"
# fixtureによるモックオブジェクト再利用
mock_method.return_value = "mocked"
mock_method_2.return_value = "mocked 2"
from mymodule.my_classes import MyClass
from mymodule.other_classes import OtherClass
my_class = MyClass()
other_class = OtherClass()
assert my_class.my_method() == "mocked"
assert my_class.my_method_2() == "mocked 2"
assert other_class.other_method() == "mocked"
モックオブジェクト使用のベストプラクティスと注意点
モックオブジェクトを作成し、fixtureで再利用する方法など見てきました。
ここまで読んでもらえれば、Pythonコードのクラスの属性であればモック化できることがわかってもらえたと思います。
理論上、テストしたいコードで使われているあらゆる名前解決可能なクラスをモックすることができます。
効果的な使用方法
モック化すべき処理は次のようなものでしょう。
- 外部APIとの通信
- データベース操作
- ファイルシステム操作
- 重い計算処理
- ランダムな値を生成する処理
実行するのにコストがかかったり、結果が安定しないものは、実際には実行せずにモック化してユニットテストを実行するのがオススメです。
よくある落とし穴
モック化のコードは、往々にして複雑になりがちなので、fixtureを使った再利用が有効です。
ただ、既存のメソッドをなんでもかんでもモック化するのはアンチパターンです。実際のコードの挙動をテストするには、必要最小限のメソッドのみをモック化し、それ以外をユニットテスト実行で実際に処理することが大事です。
任意のクラスの属性がモック化できますが、依存先の外部ライブラリのクラスなどをモック化すると、外部ライブラリで変更があった時にテストを全て修正する必要があります。
再利用性の向上
- 最初は
mocker.patch
でテスト内部にモックオブジェクト定義を追加し、複数テストケースで使われるものはpytest.fixture
として再利用する- 利点: テストケースごとの柔軟性と再利用性のバランスが取れる
- モック化するメソッドには外部ライブラリのインターフェイスをそのまま使うのではなく、自前でラップインターフェイスを定義し、そのメソッドをモック化する
- 利点:外部ライブラリへの依存性を減らす
結論
冒頭にも載せましたが、結論を再掲します。
Q. Pythonにおける "モックオブジェクト" とは何か?
A. テストで用いられる実際のオブジェクトの動作を模倣する擬似オブジェクト
Q. どのようにしてモックを実現しているのか?
A. モック対象のクラス属性を一時的にモックオブジェクトに書き換える処理をしている
Q. モックオブジェクト使用のベストプラクティスは?
A. unitest.mockの挙動を理解した上で、pytest-mock を使う。必要最小限のモック化を行い、再利用する場合はpytest.fixtureを活用する。
まとめ
ここまで読んでいただきありがとうございました!
本格的にモックオブジェクトの内部実装にまで踏み込んでる記事があまりなかったので書いてみました。
参考になると幸いです。
モックを適切に使って、安心安全なPythonスクリプトを大胆に書いていきましょう。
面白かったらいいね・シェアしてもらえると嬉しいです!
Discussion