pytest/unittest で「オブジェクト指定しづらい関数」をモックに置き換える

mull

Python のユニットテストにおいて関数のモック化をあとから考えることになってしまったとき、既存の設計のままでそこそこなテストを書く方法があることを知ったので残しておきます。

要はインポート系の問題やクラス化されていない関数指定などの問題を回避できるよ、という話です。

あてはまる状況とやりたいこと

例えばこんなディレクトリ構成で、

root
 └─ connection.py
 └─ cake.py

cake.py はこんなシーンだったとする。

import connection

def make_cake():
  dish = []
  if has_strawberry(my_fridge):
    dish.append(my_fridge["berry"])
  return dish

def has_strawberry(fridge):
  conn = connect(fridge)
  return "berry" in conn

make_cake()の単体テストを考えているとします。

冷蔵庫(fridge) がハイテクすぎるので、中にイチゴがあるか確認するためにはネットワークコネクションを握る必要があります。そしてこのとき使うconnectメソッドは環境依存があるためにローカルの開発環境では失敗するとしましょう。

こうなると

  • has_strawberry()
  • connect()

のどちらかをモックにする必要がありますが、今回はhas_strawberry()をダミー化します。make_cake()の単体テストなので、他との依存はなるべく疎にするべきです。

こうなったときに取り得る選択肢は

  • unittest.mockMock()およびMagicMock()
  • monkeypatchsetattr()

などですが、これらはオブジェクトとしてモック対象を識別できるようになっていないとモックとして指定ができないのが普通です。関数名を文字列で直接指定したりできないってことですね。

そんなわけで、

  • クラスとクラスメソッドの関係になっていなかった!
  • プロダクションコードを含むファイルをモジュールごとインポートできればいいけど、色々事情があってそれは無理!

みたいなときが想定シーンということになります。

unittest.mock モジュールの patch を使う

標準パッケージの unittest 内にある mock モジュール、その中の patch メソッドを使います。

>>> from unittest.mock import patch
>>> patch
<function patch at 0x00000242CB7DF288>

メソッドと言ってますが実際はデコレータとして使います。
patch.object()のように普通のメソッドとしても使えますがそれは今回の趣旨に相対する方の使い方なので割愛

from .. import cake
from unittest.mock import patch

@patch("cake.has_strawberry")
def test_make_cake():
  result = make_cake()
  assert result == ["berry"]
root
 └─ connection.py
 └─ cake.py
 └─ tests
      └─ test_cake.py

そう、この例で分かるようにモックに置き換えたい関数の指定を文字列で指定できるので実際にインポートなどが発生しない点が魅力です(今回はミニマムな例の都合上cakeをインポートしちゃってますが)

とはいえ、一応はオブジェクト指定(ドット繋ぎ)の表現がされていることは前提です。

上記を

@patch("has_strawberry")  # <- "cake." をなくした
def test_make_cake():
  result = make_cake()
  assert result == ["berry"]

のようにしてもモック対象として識別はされない。

今回はテスト対象コードを含むモジュールの中にモック対象も存在していたので恩恵があまりないけど、他のなんやかんやと色々事情があるときにはとても助かる。monkeypatchsetattr()より感覚的にも使いやすいです。

モック関数の使い方

@patchでデコレータをつけると、デコレートされたテスト関数の引数には自動的にモック化されたオブジェクトが渡されてきます。

@patch("cake.has_strawberry")
def test_make_cake(mock_func):
  mock_func.return_value = True
  result = make_cake()
  assert result == ["berry"]

モックオブジェクトのメンバとして

  • return_value
  • side_effect

などがあるので、これを指定することで任意の返り値を制御できます。他に何があるかはここを参照。

デコレータの第二引数以降で指定することも可能です。

@patch("cake.has_strawberry", return_value=True)
def test_make_cake():
  result = make_cake()
  assert result == ["berry"]

for 文の中で呼ばれるから毎回違う値を返したいんですけど…

テスト対象コードがこんなんに変わったとしましょう。

def make_cake():
  dish = []
  for i in range(len(my_strawberry)):
    if has_strawberry(my_fridge):
      dish.append(my_fridge["berry"])
    else:
      if i == 3:
        print("アホ")
  return dish

つまり指定の値は返してほしいんだけど、条件網羅のために繰り返し回数によって返却値を変えたいという場合です。

これはside_effectをリストにすることで解決できます。

@patch("cake.has_strawberry")
def test_make_cake(mock_func):
  mock_func.side_effect = [True, True, True, False]
  result = make_cake()
  assert result == ["berry"]

このような指定にすると、モック対象であるhas_strawberry()が呼ばれるたびに返り値がリストの各要素になってくれるということ。

引数に応じて返り値変えられん?

これはpatchの話というよりどちらかというとMagicMockの話だけど、ちゃんとできます。

@patch("cake.has_strawberry")
def test_make_cake(mock_func):
  def hoge(num):
    return num + 3
  mock_func.side_effect = hoge
  result = make_cake()
  assert result == ["berry"]

side_effectには関数も指定できるので、モックとして引数で受け取ってきた関数の返り値をその別の関数にすれば OK 。今回の例だと本当の引数は冷蔵庫のはずだけども。

ちなみにMock()MagicMock()はほぼ同じものだそうで、公式は「今は特に気にせずMagicMock()を使えばいいよ」と言っています

例外の送出もしたいなあ!

当然返り値に例外を送出させたいときもあるはず。

シンプルにこれで OK 。

mock_func.side_effect = Exption()

もちろん前述のリスト化と併用できます。

Comments

タイトルとURLをコピーしました