単体テストによく使われる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