Help us understand the problem. What is going on with this article?

【python】mockのサンプルコードまとめ(@patch, return_value, side_effect)

More than 1 year has passed since last update.

本日は、こちらでLTをしました。

【20代限定・参加費無料】若手サーバエンジニア・サーバ業界関係者交流会2019 in Tokyo

登壇資料

インフラエンジニアが考えてみた Mockの使いどころ

それにあたり、今まで雰囲気で理解したmockについてすこーし知見を深めることが出来たので、忘れないうちに書き留めておきます。

以下、pythonの公式ドキュメントから適宜引用します。
unittest — Unit testing framework — Python 3.7.4 documentation

テストコードの書き方

・下記のようにテストコードのなかでunittest.TestCaseを継承する

test_demo.py
import unittest

from <filename> import <Classname>

class Test<Classname>(unittest.TestCase):
    pass

・テストを実行するメソッドの名前はtest_xxxとする。
・テストメソッドが実行されるたびに、はじめにsetUpメソッド、最後にtearDownメソッドが呼ばれる。

test_demo.py
import unittest

from <filename> import <Classname>

class Test<Classname>(unittest.TestCase):

    def setUp(self):
        self.object = Classname()

    def tearDown(self):
        self.objcect = None
        self.result = None

    def test_nomock(self):
        self.result = self.object.main()
        self.assertEqual(self.result, True)

※わざわざsetUpメソッドとtearDownメソッドを呼ばなくてもいいのですが、ここではインスタンスの生成と初期化をしていることを明示的に行いたかったため、書きました。

The simplest TestCase subclass will simply implement a test method (i.e. a method whose name starts with test) in order to perform specific testing code:

Such a working environment for the testing code is called a test fixture. A new TestCase instance is created as a unique test fixture used to execute each individual test method. Thus setUp(), tearDown(), and init() will be called once per test.

テストの成否は基本的なassertEqual(X, expectedX)などとしています。

Method Checks that
assertEqual(a, b) a == b
assertNotEqual(a, b) a != b

テストコードの置き場所に応じたテスト実行コマンド

generaluser@localhost ~$ ls !(*.pyc) | grep sample.py
sample.py
test_sample.py
generaluser@localhost ~ $ python -m unittest test_sample
.....
----------------------------------------------------------------------
Ran 5 tests in 0.001s

OK
generaluser@localhost ~ $ mv test_sample.py tests/
generaluser@localhost ~ $ tree -L 3 -C | grep sample.py
├── sample.py
├── sample.pyc
│   ├── test_sample.py
│   ├── test_sample.pyc
└── test_sample.pyc
generaluser@localhost ~ $ python -m unittest tests.test_sample
.....
----------------------------------------------------------------------
Ran 5 tests in 0.001s

OK
generaluser@localhost ~ $

テストの対象となるサンプルコード

sample.py
class Sample:
    def challenge(self, conts):
        """ 
        When conts is equal to a even number, 
        return value is true.

        Otherwise, the value is false.
        """
        if conts % 2 == 0:
            return True
        else:
            return False


    def main(self):
        current_conts = 1
        while True:
            if not self.challenge(current_conts):
                current_conts = current_conts + 1
                continue
            else:
                break

        return current_conts

mockを使わないでテストする場合

test_sample.py
import unittest

from sample import Sample

class TestSample(unittest.TestCase):
    def setUp(self):
       """ Set object """
        self.obj = Sample()

    def tearDown(self):
       """ Initiallize the object """
        self.obj = None
        self.number = None

    def test00_no_mock(self):
       """ test without mock """
        self.number = self.obj.main()
        self.assertEqual(self.number, 2)

mockで差し替えたメソッドの返り値を決め打ちで設定する

#MagickMockインスタンスをimport
>>> from unittest.mock import MagicMock
#テストの対象クラスを生成
>>> from sample import Sample
>>> obj = Sample()
>>> obj.method = MagicMock(return_value=3)
>>> obj.method(3, 4, 5, key='value')
3
>> thing.method.assert_called_with(3, 4, 5, key='value')
test_sample.py
import unittest
from mock import patch, MagicMock

from sample import Sample

class TestSample(unittest.TestCase):
    def setUp(self):
        """ Set object """
        self.obj = Sample()

    def tearDown(self):
        """ Initiallize the object """
        self.obj = None
        self.number = None

    @patch('sample.Sample.challenge', return_value=2)
    def test01_mock_ok_return_value(self, chal):
        chal.return_value

        self.number = self.obj.main()
        self.assertEqual(self.number, 1)
        self.assertEqual(chal.call_count, 1)

    @patch('sample.Sample.challenge')
    def test02_mock_ok_return_value(self, chal):
        chal.return_value = 2

        self.number = self.obj.main()
        self.assertEqual(self.number, 1)
        self.assertEqual(chal.call_count, 1)

Q. logを吐くメソッドなど、テスト実行時には行いたくないメソッドがある場合はどうすれば?

そのメソッドにreturn_value="dummy"などとpatch をあてれば大丈夫です。
その際、ダミー値をあててもテストが滞りなく実行されるように、そのメソッドと他のメソッドとの関係性を確認する必要があります。
たとえば、ダミー値をあてたwrite_logメソッドの返り値csv_fileは別のメソッドであるopen_logメソッドで使われる場合、open_logメソッドにpatchを当てて引数_filenameに既存のcsvファイルを当てるといった対応が必要でしょう。

test_sample_demo.py

@patch('sample.Sample.write_log', return_value="dummy")
@patch('sample.Sample.open_log')
def test_demo(self, logger, open):
   demo_file = 'working_dir/log/logfile'

   def _open_log(_filename)
       #_filename="dummy"
       output = []
       with open(demo_file, newline='') as csvfile:
           datas = csv.reader(csvfile, delimiter=' ', quotechar='|')
           for data in datas:
               try:    
                   output.append(data)
               except:
                   raise Exception("_open_log_method_failed")
           return output

   open.side_effect = _open_log

mockで差し替えたメソッドが呼ばれた回数に応じて返り値を変更する

test_sample.py
import unittest
from mock import patch, MagicMock
from sample import Sample

class TestSample(unittest.TestCase):
    def setUp(self):
        """ Set object """
        self.obj = Sample()

    def tearDown(self):
        """ Initiallize the object """
        self.obj = None
        self.number = None


    @patch('sample.Sample.challenge')
    def test03_mock_ok(self, chal):
        """ when _conts is less than 4, return value is false. 
        Otherwise the value is true. """
        def _challenge(_conts):
            if chal.call_count <= 3:
                return False
            elif chal.call_count == 4:
                return True
            else:
                raise Exception("no_more_challenges")

        chal.side_effect = _challenge

        self.number = self.obj.main()
        self.assertEqual(self.number, 4)
        self.assertNotEqual(self.number, 2)
        self.assertEqual(chal.call_count, 4)

mockで差し替えたメソッドの引数の値に応じて返り値を変更する

test_sample.py
import unittest
from mock import patch, MagicMock

from sample import Sample

class TestSample(unittest.TestCase):
    def setUp(self):
        """ Set object """
        self.obj = Sample()

    def tearDown(self):
        """ Initiallize the object """
        self.obj = None
        self.number = None

    @patch('sample.Sample.challenge')
    def test03_mock_ok(self, chal):
        def _challenge(_conts):
            """ When conts is equal to a even number, return value is true.
            Otherwise, the value is false. """
            if _conts % 2 == 0:
                return True
            else:
                return False

        chal.side_effect = _challenge

        self.number = self.obj.main()
        self.assertEqual(self.number, 2)
        self.assertNotEqual(self.number, 4)
        self.assertEqual(chal.call_count, 2)

    @patch('sample.Sample.challenge')
    def test11_mock_ng(self, chal):
        """ When conts is equal to a even number except 2, return value is true.
        Otherwise, the value is false. """
        def _challenge(_conts):
            if _conts % 2 == 0:
                if chal.call_count == 2:
                    return False
                else:
                    return True
            else:
                return False

        chal.side_effect = _challenge

        self.number = self.obj.main()
        self.assertNotEqual(self.number, 2)
        self.assertEqual(self.number, 4)
        self.assertEqual(chal.call_count, 4)

サンプルコード

Githubでもあげているので、よろしければ!
https://github.com/gkzz/mock_sample/

sample.py

sample.py
class Sample:
    def challenge(self, conts):
        """ 
        When conts is equal to a even number, 
        return value is true.

        Otherwise, the value is false.
        """
        if conts % 2 == 0:
            return True
        else:
            return False


    def main(self):
        current_conts = 1
        while True:
            if not self.challenge(current_conts):
                current_conts = current_conts + 1
                continue
            else:
                break

        return current_conts

test_sample.py

test_sample.py

import unittest
from mock import patch, MagicMock
from sample import Sample

class TestSample(unittest.TestCase):
    def setUp(self):
        """ Set object """
        self.obj = Sample()

    def tearDown(self):
        """ Initiallize the object """
        self.obj = None
        self.number = None

    def test00_no_mock(self):
        """ test without mock """
        self.number = self.obj.main()
        self.assertEqual(self.number, 2)


    @patch('sample.Sample.challenge', return_value=2)
    def test01_mock_ok_return_value(self, chal):
        chal.return_value

        self.number = self.obj.main()
        self.assertEqual(self.number, 1)
        self.assertEqual(chal.call_count, 1)

    @patch('sample.Sample.challenge')
    def test02_mock_ok_return_value(self, chal):
        chal.return_value = 2

        self.number = self.obj.main()
        self.assertEqual(self.number, 1)
        self.assertEqual(chal.call_count, 1)



    @patch('sample.Sample.challenge')
    def test03_mock_ok(self, chal):
        def _challenge(_conts):
            """ when _conts is less than 4, return value is false. 
            Otherwise the value is true. """
            if chal.call_count <= 3:
                return False
            elif chal.call_count == 4:
                return True
            else:
                raise Exception("no_more_challenges")

        chal.side_effect = _challenge

        self.number = self.obj.main()
        self.assertEqual(self.number, 4)
        self.assertNotEqual(self.number, 2)
        self.assertEqual(chal.call_count, 4)


    @patch('sample.Sample.challenge')
    def test04_mock_ok(self, chal):
        def _challenge(_conts):
            """ When conts is equal to a even number, return value is true.
            Otherwise, the value is false. """
            if _conts % 2 == 0:
                return True
            else:
                return False

        chal.side_effect = _challenge

        self.number = self.obj.main()
        self.assertEqual(self.number, 2)
        self.assertNotEqual(self.number, 4)
        self.assertEqual(chal.call_count, 2)


    @patch('sample.Sample.challenge')
    def test11_mock_ng(self, chal):
        """ When conts is equal to a even number except 2, return value is true.
        Otherwise, the value is false. """
        def _challenge(_conts):
            if _conts % 2 == 0:
                if chal.call_count == 2:
                    return False
                else:
                    return True
            else:
                return False

        chal.side_effect = _challenge

        self.number = self.obj.main()
        self.assertNotEqual(self.number, 2)
        self.assertEqual(self.number, 4)
        self.assertEqual(chal.call_count, 4)

P.S. Twitterもやってるのでフォローしていただけると泣いて喜びます!
@gkzvoice

gkzz
SoftwareDeveloper #python #ansible #docker #stackStorm #geekhouse #gkz
https://github.com/gkzz
ap-com
エーピーコミュニケーションズは「エンジニアから時間を奪うものをなくす」ため、ITインフラ自動化のプロフェッショナルとして、クラウドも含めたインフラ自動化技術で顧客の課題を解決すると同時に、SI業務の課題を解決するプロダクト・サービスを提供するNeoSIer(ネオエスアイヤー)です。
https://www.ap-com.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away