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

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

[【20代限定・参加費無料】若手サーバエンジニア・サーバ業界関係者交流会2019 in Tokyo](https://mspj.doorkeeper.jp/events/91375)

###登壇資料
[インフラエンジニアが考えてみた Mockの使いどころ](https://speakerdeck.com/gkzz/inhuraenziniagakao-etemita-mockfalseshi-idokoro)


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

以下、pythonの公式ドキュメントから適宜引用します。
[unittest — Unit testing framework &#8212; Python 3.7.4 documentation]
(https://docs.python.org/3/library/unittest.html)

#テストコードの書き方
・下記のようにテストコードのなかで`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 ](https://github.com/gkzz/mock_sample/blob/master/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](https://github.com/gkzz/mock_sample/blob/master/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](https://twitter.com/gkzvoice)