スクリプトのお勉強 技術

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

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

執筆者:

関連記事

Python Django REST Framework(REST API)の作成

1.はじめに 今回は、pythonの「Django」というWebアプリケーションフレームワークを使用し、REST APIを作成します。作成するREST APIは、これから作るであろう、Nuxt.jsか …

vscodeのRemote Developmentで「権限がない」と怒られた時

小ネタです。 vscodeのRemote Developmentにてパスワードなしログイン vscodeは時々使うのですが、リモート開発するのにRemote Developmentが便利なので使ってま …

「Python3 メモ」 独自例外クラスからの値取得/変数の内容取得

忙しい。。 この時期でなぜか忙しく、ブログ書いてる暇ない。。のでメモ書き程度。そして、内容をよく忘れるやつ。。 python3の独自例外クラス 例外クラスからの値取得方法をいつも忘れます。ある関数or …

Djangoのurls.pyにはまった。。

けっこうハマった。。 Django 2.2.4の話。以下のエラーを修正するのに、とっても時間がかかった。 django.urls.exceptions.NoReverseMatch: Reverse …

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

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