テストコードで活躍する mock。
だが毎回 これってどこをmockすればいいんだ…… と必要以上に mock.patch
を書いてしまいます。
python の公式ドキュメントや解説記事では、mock 単体の振る舞いについて紹介していることが多く、最初の頃は でも実際どう組み込めばいいの? と悩むことが多かったです。
そんなわけで、実装した python のクラスに対して unittest.Testcase のテストコードで mock を使ってみて、仕様を確認してみます。
実行環境
- macOS 12.4 (Monterey)
- python3.8
テストするクラス
テストで mock が必要になるのは、個人的には大きく 2 つあるかなと個人的に思っています。
- 外部にアクセスする
- e.g.
requests
, boto3
, データベース
- テストケース
を考えるのが面倒 が複雑
- e.g. クラスの要素でクラスを持つ, 実行環境(本番/開発)によって動作が異なる
外部アクセスに関してはクラスの内部で requests
を呼び出すみたいなこともあるので、後者とも関連してきます。
よってまずは テストケースを考えるのが面倒なのでmockしてしまおう と思えるクラスを作ります。
preprocess.py
に RSS feed*1 から情報を抽出するクラス RSSParser
を宣言します。
python 標準の ElementTree を使って解析するクラスです。
import xml.etree.ElementTree as ET
class RSSParser:
def __init__(self, text):
self.text = text
self.root = ET.fromstring(text)
def get_title_name(self):
title_element = self.root.find("channel").find("title")
return title_element.text
非常にまどろっこしやり方をしていますが、テストケースを色々試すための実装ということで目をつぶってください。
以下のようなファイル構成でテストコード test_preprocess.py
を置きます。
.
└── api
├── preprocess.py
└── tests
└── test_preprocess.py
unittest の実行は以下。
python -m unittest api/tests/test_preprocess.py
テストを書く
TestRSSParser
を実装します。
初期値で渡すテキスト (XML 形式) を考えるのが面倒なので、mock で解決しましょう。
※ ElementTree は正しい仕様のテキストを渡せば正しく返ってくるという強い仮定のもと mock します。
どこにpatchするか
import xml.etree.ElementTree as ET
を mock するにはどこに patch すればいいのか。
mock 覚えたての pythonista は絶対つまずくだろう部分です。
from unittest import TestCase
from unittest.mock import Mock, patch
from api.preprocess import RSSParser
@patch("xml.etree.ElementTree")
class TestRSSParser(TestCase):
def test_et_mock(self, et):
p = RSSParser("text")
>>
Traceback (most recent call last):
File "/XXX/.pyenv/versions/3.8.0/lib/python3.8/unittest/mock.py", line 1342, in patched
return func(*newargs, **newkeywargs)
File "/XXX/api/tests/test_preprocess.py", line 11, in test_et_fail
p = RSSParser("test_et_mock")
File "/XXX/api/preprocess.py", line 7, in __init__
self.root = ET.fromstring(text)
File "/XXX/.pyenv/versions/3.8.0/lib/python3.8/xml/etree/ElementTree.py", line 1321, in XML
return parser.close()
File "<string>", line None
xml.etree.ElementTree.ParseError: syntax error: line 1, column 0
これは patch できません。 xml.etree.ET
でも同様。
なぜできないのか。
その理由は どこにパッチするか に書いてあります。
基本的な原則は、オブジェクトが ルックアップ されるところにパッチすることです。
テストしたいクラス RSSParser
が self.root = ET.fromstring(text)
を呼び出す前に、 インポートされた ET
を patch します。
つまり正しい patch の場所は api.preprocess.ET
です。
@patch("api.preprocess.ET")
class TestMain(TestCase):
def test_et_mock(self, et):
p = RSSParser("text")
patchした値、 MagicMock
patch された ET
にはデフォルトで MagicMock オブジェクトが入っています。
MagicMock は属性、関数にアクセスすると新しい MagicMock オブジェクトを返します。
mock = MagicMock()
mock.fromstring
<MagicMock name='mock.fromstring' id='4373352064'>
mock.fromstring()
<MagicMock name='mock.fromstring()' id='4373475632'>
mock.fromstring("text")
<MagicMock name='mock.fromstring()' id='4373475632'>
よって、
self.root = ET.fromstring(text)
では、self.root
に MagicMock が代入されます。
mockの返却値を設定する return_value
MagicMock そのままでは動作が正しいかどうかのテストができないので、return_value
を使って値を設定します。
self.root
に "fromstring_return_value"
というテキストが入るようにしてみます。
@patch("api.preprocess.ET")
class TestRSSParser(TestCase):
def test_et_fromstring(self, et):
et.fromstring = Mock(return_value="fromstring_return_value")
p = RSSParser("test_et_mock")
self.assertEqual(p.root, "fromstring_return_value")
上記は ET
を patch しているので et.fromstring
に return_value
を設定していますが、
fromstring
を直接 patch すればデコレーターの引数でも設定できます。
@patch("api.preprocess.ET.fromstring", return_value="fromstring_return_value")
class TestRSSParser2(TestCase):
def test_et_mock(self, et):
p = RSSParser("test_et_mock")
self.assertEqual(p.root, "fromstring_return_value")
深い場所にある属性をmockする nested mock/ chained call
get_title_name
をテストします。
返却値が self.root.find("channel").find("title").text
と深い場所にある場合はどうすればいいでしょうか。
まず .text
で string を返す mock を用意します。
title_mock = Mock()
title_mock.text = "title_string"
次に、RSSParser に title_mock
を組み込みます。
単純に代入しようとすると以下になりますが、これだと "title_string" は返ってきません。
@patch("api.preprocess.ET")
class TestRSSParser(TestCase):
def test_get_title_name(self, et):
parser = RSSParser("text")
title_mock = Mock()
title_mock.text = "title_string"
parser.root.find.find.return_value = title_mock
self.assertEqual(parser.get_title_name(), "title_string")
>>
AssertionError: <MagicMock name='ET.fromstring().find().find().text' id='4373959920'> != 'title_string'
chained call をモックする にあるように return_value
を活用します。
self.root.find("channel")
の返す値が .find("title")
を呼び出すので、
parser.root.find.find
ではなく parser.root.find.return_value.find.
とすべきです。
これを踏まえると以下のように書けます。
@patch("api.preprocess.ET")
class TestRSSParser(TestCase):
def test_get_title_name(self, et):
parser = RSSParser("text")
title_mock = Mock()
title_mock.text = "title_string"
parser.root.find.return_value.find.return_value = title_mock
self.assertEqual(parser.get_title_name(), "title_string")
また、title_mock を直接渡すことも可能です。
parser.root.find.return_value.find.return_value = Mock(text="title_string")
参考資料
細々とした工夫についても別途まとめる予定。