エイエイレトリック

なぐりがき

pythonのunittest.Testcaseでmockする・patchする

テストコードで活躍する 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.pyRSS 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

# NG pattern
@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 でも同様。

なぜできないのか。 その理由は どこにパッチするか に書いてあります。

基本的な原則は、オブジェクトが ルックアップ されるところにパッチすることです。

テストしたいクラス RSSParserself.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")
        # 別の書き方
        # et.fromstring.return_value = "fromstring_return_value"
        p = RSSParser("test_et_mock")
        self.assertEqual(p.root, "fromstring_return_value")

上記は ET を patch しているので et.fromstringreturn_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):
    # NG pattern
    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
        # configure_mockを使った別の書き方
        # parser.root.configure_mock(**{"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")

参考資料

細々とした工夫についても別途まとめる予定。