Pythonのテストコードでmockを使ってみた

こんにちは、みかみです。

そろそろ梅の良い季節v(今週末は、湯島天神梅まつり?!

はじめに

やりたいこと

  • Web APIからレスポンスを取得する Python コードのテストをしたい
  • HTTP接続できない環境でもテストできるようにしたい
  • Python で mock を使ってみたい!

動作環境

  • Windows10(Mac VMware Fusion)
  • Python 3.6.0(unittest を pip install 済み)

やってみた

Web APIからレスポンスを取得する関数のテスト

お天気APIで、東京のお天気を取得するコードです。

Python の HTTPライブラリ requests でGETリクエストを投げてレスポンスを取得しています。

import requests

def get_resp(url):

    resp = requests.get(url)
    if resp.status_code != 200:
        print('Get Data Failed...(error code : {})'.format(resp.status_code))
        raise

    return resp.json()

def main():
    url = 'http://weather.livedoor.com/forecast/webservice/json/v1?city=130010'

    actual = get_resp(url)
    print(actual)

if __name__ == '__main__':
    main()

実行すると、APIの response を print します。

C:\Users\mikami.yuki\work\apiTest>py get_weather.py
{'pinpointLocations': [{'link': 'http://weather.livedoor.com/area/forecast/1310100', 'name': '千代田区'}, {'link': 'http://weather.livedoor.com/area/forecast/1310200', 'name': '中央区'}, {'link': 'http://weather.livedoor.com/area/forecast/1310300', 'name': '港区'}, {'link': 'http://weather.livedoor.com/area/forecast/1310400', 'name': '新宿区'}, {'link': 'http://weather.livedoor.com/area/forecast/1310500', 'name': '文京区'}, {'link': 'http://weather.livedoor.com/area/forecast/1310600', 'name': '台東区'}, {'link': 'http://weather.livedoor.com/area/forecast/1310700', 'name': '墨田区'}, {'link': 'http://weather.livedoor.com/area/forecast/1310800', 'name': '江東区'}, {'link': 'http://weather.livedoor.com/area/forecast/1310900', 'name': '品川区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311000', 'name': '目黒区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311100', 'name': '大田区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311200', 'name': '世田谷区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311300', 'name': '渋谷区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311400', 'name': '中野区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311500', 'name': '杉並区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311600', 'name': '豊島区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311700', 'name': '北区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311800', 'name': '荒川区'}, {'link': 'http://weather.livedoor.com/area/forecast/1311900', 'name': '板橋区'}, {'link': 'http://weather.livedoor.com/area/forecast/1312000', 'name': '練馬区'}, {'link': 'http://weather.livedoor.com/area/forecast/1312100', 'name': '足立区'}, {'link': 'http://weather.livedoor.com/area/forecast/1312200', 'name': '葛飾区'}, {'link': 'http://weather.livedoor.com/area/forecast/1312300', 'name': '江戸川区'}, {'link': 'http://weather.livedoor.com/area/forecast/1320100', 'name': '八王子市'}, {'link': 'http://weather.livedoor.com/area/forecast/1320200', 'name': '立川市'}, {'link': 'http://weather.livedoor.com/area/forecast/1320300', 'name': '武蔵野市'}, {'link': 'http://weather.livedoor.com/area/forecast/1320400', 'name': '三鷹市'}, {'link': 'http://weather.livedoor.com/area/forecast/1320500', 'name': ' 青梅市'}, {'link': 'http://weather.livedoor.com/area/forecast/1320600', 'name': '府中市'}, {'link': 'http://weather.livedoor.com/area/forecast/1320700', 'name': '昭島市'}, {'link': 'http://weather.livedoor.com/area/forecast/1320800', 'name': '調布市'}, {'link': 'http://weather.livedoor.com/area/forecast/1320900', 'name': '町田市'}, {'link': 'http://weather.livedoor.com/area/forecast/1321000', 'name': '小金井市'}, {'link': 'http://weather.livedoor.com/area/forecast/1321100', 'name': '小平市'}, {'link': 'http://weather.livedoor.com/area/forecast/1321200', 'name': '日 野市'}, {'link': 'http://weather.livedoor.com/area/forecast/1321300', 'name': '東村山市'}, {'link': 'http://weather.livedoor.com/area/forecast/1321400', 'name': '国分寺市'}, {'link': 'http://weather.livedoor.com/area/forecast/1321500', 'name': '国立市'}, {'link': 'http://weather.livedoor.com/area/forecast/1321800', 'name': '福生市'}, {'link': 'http://weather.livedoor.com/area/forecast/1321900', 'name': '狛江市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322000', 'name': '東大和市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322100', 'name': ' 清瀬市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322200', 'name': '東久留米市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322300', 'name': '武蔵村山市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322400', 'name': '多摩市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322500', 'name': '稲城市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322700', 'name': '羽村市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322800', 'name': 'あきる野市'}, {'link': 'http://weather.livedoor.com/area/forecast/1322900', 'name': '西東京市'}, {'link': 'http://weather.livedoor.com/area/forecast/1330300', 'name': '瑞穂町'}, {'link': 'http://weather.livedoor.com/area/forecast/1330500', 'name': '日の出町'}, {'link': 'http://weather.livedoor.com/area/forecast/1330700', 'name': '檜原村'}, {'link': 'http://weather.livedoor.com/area/forecast/1330800', 'name': '奥多摩町'}], 'link': 'http://weather.livedoor.com/area/forecast/130010', 'forecasts': [{'dateLabel': '今日', 'telop': '晴のち曇', 'date': '2017-02-08', 'temperature': {'min': None, 'max': None}, 'image': {'width': 50, 'url': 'http://weather.livedoor.com/img/icon/5.gif', 'title': '晴のち曇', 'height': 31}}, {'dateLabel': '明日', 'telop': '曇時々雨', 'date': '2017-02-09', 'temperature': {'min': {'celsius': '2', 'fahrenheit': '35.6'}, 'max': {'celsius': '5', 'fahrenheit': '41.0'}}, 'image': {'width': 50, 'url': 'http://weather.livedoor.com/img/icon/10.gif', 'title': '曇時々雨', 'height': 31}}, {'dateLabel': '明後日', 'telop': '曇時々晴', 'date': '2017-02-10', 'temperature': {'min': None, 'max': None}, 'image': {'width': 50, 'url': 'http://weather.livedoor.com/img/icon/9.gif', 'title': '曇時々晴', 'height': 31}}], 'location': {'city': '東京', 'area': '関東', 'prefecture': '東京都'}, 'publicTime': '2017-02-08T17:00:00+0900', 'copyright': {'provider': [{'link': 'http://tenki.jp/', 'name': '日本気象協会'}], 'link': 'http://weather.livedoor.com/', 'title': '(C) LINE Corporation', 'image': {'width': 118, 'link': 'http://weather.livedoor.com/', 'url': 'http://weather.livedoor.com/img/cmn/livedoor.gif', 'title': 'livedoor 天気情 報', 'height': 26}}, 'title': '東京都 東京 の天気', 'description': {'text': ' 日本付近は、冬型の気圧配置が緩んできました。\n\n【関東甲信地方】\n 関東甲信地方は、曇りとなっています。\n\n 8日は、気圧の谷の影響で曇りとなり、雨や雪の降る所があるでしょう。\n\n 9日は、低気圧が本州南岸を東に 進むため、曇りで時々雨や雪が降るでし\nょう。伊豆諸島では、雷を伴う所がある見込みです。\n\n 関東近海では、8日はうねりを伴い波が高いでしょう。9日はしける所が\nある見込みです。船舶は高波に注意してください。\n\n【東京地方】\n 8日は、曇りとなるでしょう。\n 9日は、曇り時々雨か雪となる見込み です。', 'publicTime': '2017-02-08T22:02:00+0900'}}

この requests lib をmock化したい!

テストコードを書きます。

from get_weather import get_resp

import unittest
from unittest import mock

# requests lib の mock
def mocked_requests_get(*args, **kwargs):
    class MockResponse:
        def __init__(self, json_data, status_code):
            self.json_data = json_data
            self.status_code = status_code

        def json(self):
            return self.json_data

    if args[0] == 'url_valid':
        return MockResponse({"key1": "value1"}, 200)

    return MockResponse({}, 404)

# テスト用クラス
class TestGetResp(unittest.TestCase):

    # 正常系確認テストケース
    @mock.patch('requests.get', side_effect=mocked_requests_get)
    def test_get_resp_ok(self, mock_get):
        json_data = get_resp('url_valid')
        self.assertEqual(json_data, {"key1": "value1"})

    # 異常系確認テストケース
    @mock.patch('requests.get', side_effect=mocked_requests_get)
    def test_get_resp_ng(self, mock_get):
        with self.assertRaises(RuntimeError):
            json_data = get_resp('url_invalid')

if __name__ == '__main__':
    unittest.main()

実行してみます。

C:\Users\mikami.yuki\work\apiTest>py test_get_weather.py
Get Data Failed...(error code : 404)
..
----------------------------------------------------------------------
Ran 2 tests in 0.002s

OK

requests.get() をmock化できました!

mockをもっとシンプルに使ってみる

例えば、

  1. main() から hoge() をコール
  2. hoge() が fuga() をコールして、fuga() の戻り値を main() に返す
  3. main() で戻り値をprint

なコードがあるとします↓

def hoge():
    return fuga()

def fuga():
    return 'Here is Fuga!'

def main():
    res = hoge()
    print(res)

if __name__ == '__main__':
    main()

実行してみると

C:\Users\mikami.yuki\work\apiTest>py test_mock.py
Here is Fuga!

ちゃんと、fuga() の戻り値をprintしてくれます。

この hoge() のユニットテストをしたいので、テストコードから fuga() をコールします。

が、 fuga() とは分離したいので、fuga() を mock に置き換えてみます↓

from unittest import mock

def hoge():
    return fuga()

def fuga():
    return 'Here is Fuga!'

# fuga() を patch(mock化)
@mock.patch('__main__.fuga')
def test_hoge(mock_fuga):
    # mock の戻り値を設定
    mock_fuga.return_value = 'Here is Mock!'

    res = hoge()
    print(res)

if __name__ == '__main__':
    test_hoge()

たったこれだけ!

実行してみます。

C:\Users\mikami.yuki\work\apiTest>py test_mock.py
Here is Mock!

ちゃんと mock が呼ばれました!

おわりに(所感)

なに、これ、すごい!(めっさらくちん@@v

  • Java → Junit → Eclipse で Plugin がどうとか、アノテーションがどうとか。。(あせ
  • PHP@Laravel + Mockery → UnitTestのこと考えて初めからファサードクラス実装したり、composer で Mockery 入れたり、めんどく(ry

→ Python の unittestってすぐれものv

※個人的感想すみませんmm(近頃 Python 信者なもので。。

参考