スクリプトのお勉強 技術

Pythonのmock.patchを使ってみる

投稿日:

単体テストによく使われるMockライブラリ

Pythonで単体テストを行う際、実際のライブラリを使用してしまうと、実際の環境を用意しなければいけません。

例えば、データベースのテストをする際に、データベースの環境を前提とするのではなく、ロジックだけテストしたいことはよくあります。

その際に、データベース等にアクセスするライブラリ側をざっくりと変更してしまう方法がMockによるテスト方法です。

Python3はほぼ標準で用意されている

Python3はmockというライブラリが既に用意されていて、使えるようになっています。

私がやるとできないことが多い。。

でも、私がやることできないことが多いので、試してみます。

テストサンプル1(mailsend)

ディレクトリ構成を以下にします。

Pipfile
Pipfile.lock
lib/
  mail.py
  upper.py
tests/
  test_mail.py
  test_upper.py

以下のような、lib/mail.pyのmailsendクラスをテストするとします。

from smtplib import SMTP


def mailsend(from_addr, to_addr, body):
    msg = {}
    msg['Subject'] = 'test'
    msg['From'] = from_addr
    msg['To'] = to_addr

    # Send the message via our own SMTP server.
    s = SMTP('localhost')
    s.send_message(msg)
    s.quit()

py.test使用によるMockライブラリの使用方法

Mockライブラリを使用したテストコード(tests/test_mail.py)は以下のようになります。以下は、デコレーターとして設定する方法です。

import os
import sys
from mock import patch
from unittest import TestCase

# rootを一つ上のディレクトリに指定
sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__name__)), 'lib'))

from mail import mailsend


class TestMail(TestCase):

    @patch('mail.SMTP')
    def test_mail(self, smtp):

        mailsend('from', 'to', 'body')

        for name, args, kwargs in smtp.mock_calls:
            # モック内容の表示
            # print(name, args, kwargs)
            if name == '().send_message':
                calls = args[0]

        self.assertEqual(calls['To'], 'to', 'send_message called')

テストを実行すると以下になります。

$ pipenv run py.test tests/test_mail.py
====================================================================================================== test session starts ======================================================================================================
platform linux -- Python 3.6.8, pytest-5.2.1, py-1.8.0, pluggy-0.13.0
rootdir: /home/tanino/script-plactice/practice_the_script/python/mock
collected 1 item

tests/test_mail.py .

重要なのは@patch('mail.SMTP')です。この意味は、「mailパッケージ内のSMTPモジュールをMockに置き換える」という意味になります。

class TestMail:

    @patch('mail.SMTP')
    def test_mail(self, smtp):

‘mail.SMTP’は、なんとなくですが、私の直感には反する設定です。
何も考えないと、以下のSMTPをMockに変えるのだから

from smtplib import SMTP

smtplib.SMTPだと思うのですが、smtplib.SMTPと設定しても動作しません。
その理由は、以下に書いてあるのですが、、

https://docs.python.org/ja/3/library/unittest.mock.html#where-to-patch

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

分かるような分からないような説明です。少なくとも私にはわかりませんでした。つまり上記の場合、import「先」にpatchが当たるように、
「パッケージ名.import「先」」
を設定しているというので一応納得ができます。

あとは、Mockを使うときには、以下をよく使います。

for name, args, kwargs in smtp.mock_calls:
    # モック内容の表示
    # print(name, args, kwargs)

mockオブジェクトの中に何が入ってるか分からない場合は、print文で表示してみるのはよくやります。

テストサンプル2

これだけなら比較的簡単なのですが、patchの引数に何を指定すべきかよく分からない場合があります。
それは、間接的にimportしているときです。

例えば、lib/upper.pyという中間ライブラリがあったとして、そのupper.py内部で上記のmailsendが呼び出されているとします。

lib/upper.pyは以下の感じです。

class Upper:

    def middle(self):
        from mail import mailsend

        mailsend('2from', '2to', '2body')

lib/upper.pyをテストするのは、以下のtests/test_upper.pyです。

import os
import sys
from mock import patch
from unittest import TestCase

sys.path.append(os.path.join(os.path.dirname(os.path.dirname(__name__)), 'lib'))

from upper import Upper


class TestMiddle(TestCase):

    @patch('mail.SMTP')
    def test_mail(self, smtp):

        Upper().middle()

        for name, args, kwargs in smtp.mock_calls:
            # モック内容の表示
            # print(name, args, kwargs)
            if name == '().send_message':
                calls = args[0]

        self.assertEqual(calls['To'], '2to', 'send_message called')

以下のように両方通すと通ります。

$ pipenv run py.test tests
====================================================================================================== test session starts ======================================================================================================
platform linux -- Python 3.6.8, pytest-5.2.1, py-1.8.0, pluggy-0.13.0
rootdir: /home/tanino/script-plactice/practice_the_script/python/mock
collected 2 items

tests/test_mail.py .
tests/test_upper.py .

ここでの重要な所は@patch('mail.SMTP')にあります。
mock.patchは、「テストコードから見て」どこのimport「先」をpatchするかが重要で、呼び出す関数がどのようにimportしているかは関係ないのですね。。

ここらへんがいつも混乱して、動作しない原因のような気がします。

おわりに

Mock系は便利ではありますが、使い過ぎると、Mock自体をテストしている感じになってしまうので多用しないようにしています。

これもテストコードでは動作しますが、実際に使うと動かなかったりするんですよね。。そこらへんはしょうがない。。

参考

  • グローバルスコープ自体を変えてしまう方法
    https://atsuoishimoto.hatenablog.com/entry/2013/07/27/000000

-スクリプトのお勉強, 技術

執筆者:

関連記事

Certbot(snap版)の起動が失敗した件

certbotの起動が失敗した 小ネタです。 なぜかSSL証明書が更新されず、Let’s Encrypt Expiry Botからたくさんメールが来るなーと思ったらcertbotの起動が失 …

Markdown to HTML(grip)

1.はじめに 最近の文書はほとんどMarkdownで書くのですが、それをHTMLに変換する方法を調べました。 ただし、私がMarkdownと思っている文法は「github-flavored-markd …

seaborn + Pandas + Python によるグラフ描画(その2: グラフ描画編)

前回の続き 前回の続きです。 折れ線グラフ まずは折れ線グラフを描画したいと思います。 描画するのは以下です。 運用商品(4つ)日経平均 以下で起動します。引数(dataset-2017-201908 …

Python3/ある日付から日付までの月/日/時間ごとの時刻を算出する

小ネタです。 要するに、時刻A と 時刻B を指定したときの、各時間間隔での時刻取得したかったです。 当初はdateutilを使う方法でなく、自前で実装しようと思ったのですが、面倒なことに気づきました …

WSL2のubuntu 22.04 LTSを24.04 LTSにアップデート

WSL2 24.04LTS アップデート

google オプトアウト Click here to opt-out.