$30 off During Our Annual Pro Sale. View Details »

過去の私に伝えたい、 Pythonのunittest.mockのあれこれ

mizzsugar
October 09, 2019

過去の私に伝えたい、 Pythonのunittest.mockのあれこれ

mizzsugar

October 09, 2019
Tweet

More Decks by mizzsugar

Other Decks in Programming

Transcript

  1. 過去の私に伝えたい、
    Pythonのunittest.mockの
    あれこれ
    2019/10/09 みんなのPython勉強会
    @mizzsugar0425

    View Slide

  2. お前、誰よ
    ● Twitter : @mizzsugar0425
    ● PythonでWeb開発しています。
    ○ 仕事:Django, Vue.js, MySQL
    ○ 趣味:Pyramid, Nuxt.js, TypeScript, PostgreSQL
    ● Djangogirlsコーチやってます。先日、翻訳デビューしました!
    ● コーヒーと自転車が好きです。

    View Slide

  3. 前提
    ● unittest.mock特有の話ではないので、pytestでも使えます。
    ● 単体テストのことを話します。E2Eテストやシステムテストや結合テストについては
    話しません。
    ● テスト駆動開発については話しません。
    ● スライド内に収めるためにサンプルコードがPEP8に反していることがありますがご
    了承ください。
    ● 議論は大歓迎です! ただし、強い言葉や否定の言葉のない優しい世界でお願い
    します(>人<)

    View Slide

  4. 単体テストって?
    ● 単体テストの目的
    ○ プロダクトコードが意図した通りに動くことを確認する
    ● 単体テストを書いたら嬉しいこと
    ○ いつでも同じテストができるので不安なくリファクタリングできる
    ○ 振る舞いを変更した場合に正しく振る舞えるかすばやく確認できる
    ○ テストを書くことでテスト対象となる関数やライブラリの最初のユーザーとなる。それによって それら
    が使いやすいかを判断できる。

    View Slide

  5. こういう時に単体テスト書いていると嬉しい
    calculate.py
    飲食店で注文した商品の合計に消費税を加えて購入金額を計算する関数です。
    この関数では金額の合計に 1.08をかけるだけですが・・・
    ※今回の例はわかりやすさを重視して厳密な軽減税率のルールには則っていません。
    import dataclasses
    from typing import Iterable
    @dataclasses.dataclass(frozen=True)
    class Item:
    name: str
    price: int
    def price(items: Iterable[Item]) -> int:
    # 簡単にするためにひとまず int()で端数処理します
    return int(sum(item.price for item in items) * 1.08)

    View Slide

  6. 軽減税率が導入されるようになった
    calculate.py
    import dataclasses
    from typing import Iterable
    @dataclasses.dataclass(frozen=True)
    class Item:
    name: str
    price: int
    def price(items: Iterable[Item], eat_in: bool) -> int:
    if eat_in:
    return int(sum(item.price for item in items) * 1.1)
    return int(sum(item.price for item in items) * 1.08)

    View Slide

  7. キャッシュレスなら5%減額されるようになった
    calculate.py
    この後に更にルールの追加、変更・・・ となると
    手動で関数の振る舞いが正しいかを確認するのが大変です。
    import dataclasses
    from typing import Iterable
    @dataclasses.dataclass(frozen=True)
    class Item:
    name: str
    price: int
    def price(items: Iterable[Item], eat_in: bool, cache_less: bool) -> int:
    tax_rate = 1.10 if eat_in else 1.08
    reduction = 0.95 if cache_less else 1
    return int(sum(item.price for item in items) * tax_rate * reduction)

    View Slide

  8. 単体テストが書いていると
    import unittest
    import src.calculate
    class TestCalculate(unittest.TestCase):
    def setUp(self):
    self.item_1 = src.calculate.Item(name='sample_1', price=500)
    self.item_2 = src.calculate.Item(name='sample_2', price=1000)
    def test_calculate_sum(self):
    items = [self.item_1, self.item_2]
    actual = src.calculate.price(items)
    expected = 1620
    self.assertEqual(expected, actual)

    View Slide

  9. 軽減税率導入に伴い、テストケース見直し
    import unittest
    import src.calculate
    class TestCalculate(unittest.TestCase):
    """中略"""
    def test_calculate_sum_eatin(self):
    items = [self.item_1, self.item_2]
    actual = src.calculate.price(items, eat_in=False)
    expected = 1620
    self.assertEqual(expected, actual)
    def test_calculate_takeout(self):
    items = [self.item_1, self.item_2]
    actual = src.calculate.price(items, eat_in=True)
    expected = 1650
    self.assertEqual(expected, actual)
    振る舞いの変更
    に応じて
    テストメソッド名
    も変更
    イートインの場合と
    テイクアウトの場合の
    テストケースを作成

    View Slide

  10. キャッシュレス導入に伴い、テストケース見直し
    class TestCalculate(unittest.TestCase):
    """中略"""
    def test_calculate_sum_eatin_cache(self):
    items = [self.item_1, self.item_2]
    actual = src.calculate.price(items, eat_in=False, cache_less=False)
    expected = 1620
    self.assertEqual(expected, actual)
    def test_calculate_takeout_cache(self):
    items = [self.item_1, self.item_2]
    actual = src.calculate.price(items, eat_in=True, cache_less=False)
    expected = 1650
    self.assertEqual(expected, actual)
    def test_calculate_sum_eatin_cache_less(self):
    items = [self.item_1, self.item_2]
    actual = src.calculate.price(items, eat_in=False, cache_less=True)
    expected = 1539
    self.assertEqual(expected, actual)
    def test_calculate_takeout_cache_less(self):
    items = [self.item_1, self.item_2]
    actual = src.calculate.price(items, eat_in=True, cache_less=True)
    expected = 1567
    self.assertEqual(expected, actual)

    View Slide

  11. 単体テストがないと・・・
    ● 変更があるたびに手動でテストしないといけない
    ● プロダクトコードのみだと何を渡した時に何が返ってくるか第三者に分かりづらい
    単体テストがあると・・・
    ● テストを実行するためのコマンドだけで正しく動いているか確認できる
    ● テストケースの内容をみるとどんな条件でどのように動くか確認できる
    ● レビュー時にテストケースをみることで確認すべき観点の過不足を確認できる
    単体テストがあると便利

    View Slide

  12. unittest.mockとは
    ● Pythonでテストする際に利用するPythonの標準ライブラリ。プロダクトコードの一部
    をモックオブジェクトに置き換え、作成した関数やモジュールの振る舞いに関するア
    サーションメソッドを実行できる
    ● モックオブジェクトに置き換えることで構築に手間がかかるオブジェクトを高速に利
    用できる
    ● 「mock」という言葉には「まねる」「まがいの」という意味があります。他のオブジェク
    トや関数のふりをした偽物として振る舞うイメージをもっていただければ
    ※本発表では、プロダクトコードの一部を置き換えたオブジェクトを「モックオブジェ
    クト」と呼び、置き換えることを「モックする」と呼びます。スタブとモックという概念が
    ありますが、時間の都合上言及しないので厳密には分けません。

    View Slide

  13. 例えばこんな関数をテストしたいとします
    kuji.py
    import random
    def kuji() -> str:
    fortune_number = random.randrange(10)
    if fortune_number % 2 == 0:
    return 'あたり'
    return 'はずれ'

    View Slide

  14. import unittest
    import src.kuji
    class TestKuji(unittest.TestCase):
    def test_win_if_multiple_of_2(self):
    actual = src.kuji.kuji()
    expected = 'あたり'
    self.assertEqual(expected, actual)
    def test_fail(self, mock_random_number):
    actual = src.kuji.kuji()
    expected = 'はずれ'
    self.assertEqual(expected, actual)
    テストが通る時と通らない時がある事件発生
    これじゃあ
    このテストが通れば安心と自
    信をもちきれない!!

    View Slide

  15. import unittest
    import src.kuji
    class TestKuji(unittest.TestCase):
    def test_win_if_multiple_of_2(self):
    actual = src.kuji.kuji()
    expected = 'あたり'
    self.assertEqual(expected, actual)
    def test_lose(self, mock_random_number):
    actual = src.kuji.kuji()
    expected = 'はずれ'
    self.assertEqual(expected, actual)
    random.randrange(10)が実行さ
    れているため、
    test_win_if_multiple_of_2で2で
    割り切れない数字が利用されるこ
    ともある。
    同じ考え方で、test_loseで2で割り
    切れる数が利用され
    テストが通らないことも。
    なぜ不安定なテストになってしまったのか

    View Slide

  16. モックオブジェクトで安定したテストに
    class TestKuji(unittest.TestCase):
    @unittest.mock.patch('random.randrange')
    def test_win_if_multiple_of_2(self, mock_random_number):
    mock_random_number.return_value = 2
    actual = src.kuji.kuji()
    expected = 'あたり'
    self.assertEqual(expected, actual)
    @unittest.mock.patch('random.randrange')
    def test_lose(self, mock_random_number):
    mock_random_number.return_value = 1
    actual = src.kuji.kuji()
    expected = 'はずれ'
    self.assertEqual(expected, actual)
    テスト毎にランダムに値が
    出力される
    random.randrange()を
    モックします
    return_valueで
    random.randrange()が
    返す値を2に固定します
    このテストメソッドでは常に
    2が返され、安定したテス
    トになります。

    View Slide

  17. も〜っと!モックオブジェクト
    ● テストを実行する時間やタイムゾーンにテスト結果が左右されない
    ● データベースアクセスの箇所を置き換えることで、データベースの内容にテスト結果
    が左右されない
    ● ・外部APIを利用する箇所を置き換えることで、ネットワークにテスト結果が左右され
    ない。また、テストのために何回もリクエスト送って申し訳ないのがなくなる
    -> テストが失敗する外的要因を排除できる
    -> 処理内容の変更やリファクタリングを安心して行える
    ● とはいえ、モックオブジェクトに置き換えた箇所の動きは保証されていないのでよく
    テストされたものを使うなど注意が必要

    View Slide

  18. モックオブジェクトドッカ〜ン!とならないために
    1. datetime.datetime.nowをモックできない問題
    2. 外部APIを利用したテストをどうかけばいいのかわからない問題

    View Slide

  19. datetime.datetime.now()をモックできない問題
    import datetime
    def greet() -> str:
    now = datetime.datetime.now()
    if 5 <= now.hour < 12:
    return 'おはようございます'
    elif 12 <= now.hour < 18:
    return 'こんにちは'
    return 'こんばんは'
    https://t-wada.hatenablog.jp/entry/design-for-testability をPythonに書き換え
    greet.py

    View Slide

  20. import datetime
    import unittest.mock
    import pytz
    import src.greet
    @unittest.mock.patch('datetime.datetime.now')
    def test_morning(self, mock_datetime):
    mock_datetime.return_value = datetime.datetime(
    2019, 10, 1, 5, 0, 0, 0
    )
    expected = 'おはようございます'
    self.assertEqual(expected, src.greet.greet())
    -> エラーになってテストできない
    test_greet.py
    ※CPythonで書かれたモジュールのオブジェクトが
    Immutableであることが原因です。
    PyPy3.5で動作確認したところ、正常に動きました

    View Slide

  21. 案1: ライブラリ「freezegun」を使う
    from freezegun import freeze_time
    @freeze_time('2019-10-01 05:00:00')
    def test_morning(self):
    expected = 'おはようございます'
    self.assertEqual(
    expected,
    src.greet.greet()
    )
    test_greet.py

    View Slide

  22. 案2: datetime.datetime.now()を引数にもつ
    class TestGreet(unittest.TestCase):
    def test_morning(self):
    expected = 'おはようございます'
    self.assertEqual(
    expected,
    src.greet.greet(
    datetime.datetime(2019, 10, 1, 5, 0, 0, 0)
    )
    )
    テストはこんな感じになって・・・
    test_greet.py

    View Slide

  23. def greet(now: datetime.datetime) -> str:
    if 5 <= now.hour < 12:
    return 'おはようございます'
    elif 12 <= now.hour < 18:
    return 'こんにちは'
    return 'こんばんは'
    時間を引数にもたせると
    datetime.datetime.now()ではなく指定した日時で使いたい!
    という要望が出た時にも再利用できます。

    View Slide

  24. 外部APIを利用した関数のテストが書けない
    日本の天気を取得するAPIを利用してピクニックの実施を判定する関数をテストするとし
    ます。
    https://tenki.example.com/today/[郵便番号(7桁の半角数字ハイフンなし)]
    をGETでリクエストを送ると下記のようなJSONが返されます。
    {
    '郵便番号': '1050004'
    '都道府県': '東京都',
    '市区町村': '港区新橋',
    '天気': '晴れ'
    }
    存在しない郵便番号を指定してリクエストが送られたらHTTPステータスコードが404でレ
    スポンスが返されます。
    ※これは架空のAPIであり、実際には使用できません

    View Slide

  25. テスト対象の関数
    import http
    import requests
    def picnic(postal_code: str) -> str:
    request_url = f'https://tenki.example.com/today/{postal_code}'
    response = requests.get(request_url)
    if response.status_code == http.HTTPStatus.NOT_FOUND:
    raise ValueError
    if response.json().get('天気') == '晴れ':
    return 'ピクニックは決行'
    return 'ピクニックは延期'
    ピクニックの行き先の郵便番号を引数に入力します。
    晴れならばピクニックは決行、それ以外ならば延期とします。

    View Slide

  26. requests.getをモックしてネットワーク通信しない
    import unittest
    import unittest.mock
    import src.tenki
    class TestTenki(unittest.TestCase):
    @unittest.mock.patch('requests.get')
    def test_go_on_a_picnic_if_sunny(self, mock_request):
    mock_request.return_value = unittest.mock.Mock(
    status_code=200
    )
    mock_request.return_value.json.return_value = {
    '天気': '晴れ'
    }
    actual = src.tenki.picnic(1050004)
    expected = 'ピクニックは決行'
    self.assertEqual(expected, actual)
    天気しか利用しないので他の
    項目は返していませんが、
    入れても問題ありません
    GET以外の
    HTTPリクエストでも
    同様にモックしてください

    View Slide

  27. まとめ
    ● 単体テストを書くとリファクタリングや変更が安心
    ● テストを高速に安定して実行するためにunittest.mockモジュールを使おう
    ● モックオブジェクトに置き換えた箇所の動きは保証されていないのでよくテストされ
    たものを使うなど注意が必要
    ● 3rdパーティライブラリでモックできない問題を解決
    ● オブジェクトを引数に渡すなどプロダクトコードの設計を見直す解決も
    ● モックオブジェクトで外部APIを利用した関数も怖くない

    View Slide

  28. 参考文献
    ● テスト駆動開発
    https://www.amazon.co.jp/gp/product/B077D2L69C/ref=dbs_a_def_rwt_hsch_vapi_tkin_p1_i0
    ● 実践テスト駆動開発
    https://www.amazon.co.jp/%E5%AE%9F%E8%B7%B5%E3%83%86%E3%82%B9%E3%83%88%E9%A7
    %86%E5%8B%95%E9%96%8B%E7%99%BA-Object-Oriented-SELECTION-Freeman/dp/4798124583/r
    ef=sr_1_1?qid=1570540448&refinements=p_27%3ASteve+Freeman&s=books&sr=1-1&text=Steve+F
    reeman
    ● Pylons 単体テストガイドラインhttp://docs.pylonsproject.jp/en/latest/community/testing.html
    ● 現在時刻が関わるユニットテストから、テスト容易性設計を学ぶ
    https://t-wada.hatenablog.jp/entry/design-for-testability

    View Slide

  29. ありがとうございました!

    View Slide