Pythonユニットテストのモック徹底解説:仕組みから実践的な使い方まで

2024/09/17に公開

はじめに

目的

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_methodmocked を返してくれます。

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 でどのような処理をしているか調べると手っ取り早いです。

これには、以下の二つを理解する必要があります。

  1. patch(...) の処理
  2. 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 の属性 ab にセットすることができます。

つまり、以下がポイントです。

  • __enter__ で対象クラスのオリジナル属性を temp_original に退避し、その属性をモックオブジェクトに差し替えている
  • with patch(...) as mm には↑で作ったモックオブジェクトが渡される
  • __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におけるユニットテストは unittestpytest あたりが主な選択肢だと思います。

pytest-mockunittest.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