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

unittest.mockを使ってテストを書こう ~モックオブジェクトを使ってより単体テストの目的に沿ったテストに~

mizzsugar
February 29, 2020

unittest.mockを使ってテストを書こう ~モックオブジェクトを使ってより単体テストの目的に沿ったテストに~

mizzsugar

February 29, 2020
Tweet

More Decks by mizzsugar

Other Decks in Programming

Transcript

  1. unittest.mockを使って

    テストを書こう

    ~モックオブジェクトを使ってより単体テストの目的に沿ったテストに~
    2020/02/29 PyCon mini Shizuoka
    @mizzsugar0425

    View Slide

  2. お前、誰よ
    ● みずきと申します。
    ● Twitter : @mizzsugar0425
    ● 仕事はデータ分析基盤の開発・運用です。
    ○ GCP, BigQuery
    ● 趣味はPythonでWeb開発です。
    ○ Django, Pyramid, Nuxt.js, TypeScript, PostgreSQL
    ● Djangogirlsコーチやってます。
    ● コーヒーと自転車が好きです。

    View Slide

  3. このトークの対象者
    ● unittest.mockの存在は知っているけれどもイマイチ使いどころが分かって
    いない人
    ● assertTrueやassertEqualなどの基本的なテストコードの文法は知ってい
    るけれど、何のためにテストを書くかをじっくり考えたことがない人

    View Slide

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

    View Slide

  5. 本日の発表のソースコード
    https://github.com/mizzsugar/pycon_mini_shizuoka_2020

    View Slide

  6. このトークは2部に分かれます
    前半
    単体テストの目的
    後半
    単体テストでモックオブジェクトの使い方

    View Slide

  7. 前半

    View Slide

  8. 今日これだけは持ち帰ってほしい(前半)
    ● 単体テストの目的はクラスや関数の振る舞いが意図通りかの確認
    ● テストコードで単体テストを自動化
    ● 単体テストでは一つのテストで一つのことだけテスト

    View Slide

  9. 単体テストって?
    ● 単体テストの目的
    ○ 作成したクラスや関数が意図した通りに振る舞うかの確認
    ● 単体テストのテストコードを書いたら嬉しいこと
    ○ テストを実行するコマンド一つですべてのテストケースを実行してくれ
    ます
    ○ 振る舞いを変更した場合に正しく振る舞えるかコマンド一つで確認でき
    ます

    View Slide

  10. 合計額を算出する関数をテストしよう
    calculate.py
    import dataclasses
    from typing import Iterable
    @dataclasses.dataclass(frozen=True)
    class Item:
    name: str
    price: int
    def price(items: Iterable[Item]) -> int:
    return int(sum(item.price for item in items) * 1.08)

    View Slide

  11. テストコードを書いてみよう①
    import unittest
    import src.calculate
    class TestCalculate(unittest.TestCase):
    def test_tax_should_be_included_in_sum(self):
    # 合計金額に8%の消費税も含まれて算出されるか確認
    item_1 = src.calculate.Item(name='sample_1', price=500)
    item_2 = src.calculate.Item(name='sample_2', price=1000)
    items = [item_1, item_2]
    # 続きのコードは後ほど
    unittestモジュールでのテストお作法①
    unittest.TestCaseクラスを継承したクラスを作成。

    View Slide

  12. テストコードを書いてみよう②
    class TestCalculate(unittest.TestCase):
    def test_tax_should_be_included_in_sum(self):
    # 合計金額に8%の消費税も含まれて算出されるか確認
    item_1 = src.calculate.Item(name='sample_1', price=500)
    item_2 = src.calculate.Item(name='sample_2', price=1000)
    items = [item_1, item_2]
    unittestモジュールでのテストお作法②
    テストクラスにテストメソッドを作成。
    test_から始まらないと実行されません。

    View Slide

  13. テストコードを書いてみよう③
    unittestモジュールでのテストお作法③
    アサーションメソッドで
    期待通りの実行結果になるか検証
    class TestCalculate(unittest.TestCase):
    def test_tax_should_be_included_in_sum(self):
    # 中略
    actual = src.calculate.price(items)
    expected = 1620
    self.assertEqual(expected, actual)

    View Slide

  14. テストコードがあると楽に安心して変更できる
    ※今回の例はわかりやすさを重視して厳密な軽減税率のルールには則っていません。
    @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

  15. 軽減税率導入に伴い、テストケース見直し
    def test_calculate_sum_eatin(self):
    # 中略
    actual = src.calculate.price(items, eat_in=False)
    expected = 1620
    self.assertEqual(expected, actual)
    def test_calculate_takeout(self):
    # 中略
    actual = src.calculate.price(items, eat_in=True)
    expected = 1650
    self.assertEqual(expected, actual)
    振る舞いの変更
    に応じて
    テストメソッド名
    も変更
    イートインの場合と
    テイクアウトの場合の
    テストケースを作成
    関数の呼び出し方も変更

    View Slide

  16. キャッシュレスなら5%減額されるようになった
    calculate.py
    この後に更にルールの追加、変更・・・ となると
    手動で関数の振る舞いが正しいかを確認するのが大変です。
    def price(
    items: Iterable[Item], eat_in: bool, cash_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

  17. 手動でのテストだと・・・(読まなくて良いです)
    >>> import app.calculate
    >>> item1 = app.calculate.Item('item1', 500)
    >>> item2 = app.calculate.Item('item2', 1000)
    >>> items = [item1, item2]
    >>> app.calculate.price(items, True, True)
    1567
    >>> 1500 * 1.1 * 0.95
    1567.5000000000002
    >>> # ↑ int()でくくるの忘れてた(本気でミスした)
    >>> int(1500 * 1.1 * 0.95)
    1567
    >>> app.calculate.price(items, False, True)
    1539
    >>> int(1500 * 0.8 * 0.95)
    1140
    >>> # ↑ 1.08を間違えて0.8にしてしまいました(真面目にタイプミスした)
    >>> int(1500 * 1.08 * 0.95)
    1539

    View Slide

  18. キャッシュレス導入に伴い、テストケース見直し
    class TestCalculate(unittest.TestCase):
    # テイクアウトかつ現金
    def test_calculate_sum_takeout_cache(self):
    # 中略
    actual = src.calculate.price(items, eat_in=False, cash_less=False)
    expected = 1620
    self.assertEqual(expected, actual)
    # イートインかつ現金
    def test_calculate_takeout_cache(self):
    # 中略
    actual = src.calculate.price(items, eat_in=True, cash_less=False)
    expected = 1650
    self.assertEqual(expected, actual)

    View Slide

  19. class TestCalculate(unittest.TestCase):
    # 中略
    # テイクアウトかつキャッシュレス
    def test_calculate_sum_eatin_cashless(self):
    # 中略
    actual = src.calculate.price(items, eat_in=False, cash_less=True)
    expected = 1539
    self.assertEqual(expected, actual)
    # イートインかつキャッシュレス
    def test_calculate_takeout_cashless(self):
    # 中略
    actual = src.calculate.price(items, eat_in=True, cash_less=True)
    expected = 1567
    self.assertEqual(expected, actual)

    View Slide

  20. テストコードがないと・・・
    ● 変更があるたびに手動で振る舞いを確認しないといけません
    ● どこまで検証したのか第三者に分かりづらいです
    テストコードがあると・・・
    ● コマンド一つで振る舞いを確認できます
    ● テストケースの内容をみるとどう検証したか確認できます
    テストコードで単体テストを自動化しよう

    View Slide

  21. 一息つきます

    View Slide

  22. モックの話に移ります

    View Slide

  23. 後半

    View Slide

  24. 今日これだけは持ち帰ってほしい(後半)
    ● 「モック」とはオブジェクトや関数のふりをした偽物として振る舞うこと
    ● モックを使うことで関係ないところでテストが左右されないように
    ● Pythonでモックするならunittest.mock

    View Slide

  25. 外的要因にテストが左右されないことが大事
    ● 確認したいこと以外の箇所でテストの結果が左右されないべき
    ● 関数内で呼び出している関数も実行させるとテストが失敗した時に要因を
    探るのが大変なときも

    View Slide

  26. 例えば・・・

    View Slide

  27. こんな関数をテストしたいとします
    kuji.py
    import random
    def kuji() -> str:
    is_lucky = random.choice([True, False])
    if is_lucky:
    return 'あたり'
    return 'はずれ'

    View Slide

  28. テストが通る時と通らない時がある事件発生
    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):
    actual = src.kuji.kuji()
    expected = 'はずれ'
    self.assertEqual(expected, actual)
    これじゃあ
    このテストが通れば安心と自
    信をもちきれない!!

    View Slide

  29. なぜ不安定なテストになってしまったのか
    class TestKuji(unittest.TestCase):
    def test_win_if_multiple_of_2(self):
    actual = src.kuji.kuji()
    expected = 'あたり'
    self.assertEqual(expected, actual)
    random.choiceが
    実行されているため、
    Falseが利用されることもあ
    る。

    View Slide

  30. 1つのことだけ確認したい
    ● 標準モジュールで保証されているrandom.choice自体の動きまで確認したくありま
    せん
    ● random.cohiceの出力によって「あたり」が出るか「はずれ」が出るかが大事

    View Slide

  31. 関係ないものは「モック」しよう

    View Slide

  32. 関係ないものは「モック」しよう
    ● 「モック」という言葉には「偽の」「まがいの」という意味があります。
    ● 他のオブジェクトや関数のふりをした偽物として振る舞うイメージ
    ● テストしたくない箇所はテスト内で実際に実行させず、実行させる「ふり」をさせま
    しょう。
    ● ふりをさせることで、関係ないものにテストが左右されず安定したテストに近づけま
    す。

    View Slide

  33. Pythonでモックするにはunittest.mock
    ● Pythonでテストする際に利用するPythonの標準ライブラリ。
    ● プロダクトコードの一部をモックオブジェクトに置き換え、作成した関数やモ
    ジュールの振る舞いに関するアサーションメソッドを実行できる
    ● モックオブジェクトに置き換えることで構築に手間がかかるオブジェクトを高
    速に利用できる

    View Slide

  34. モックオブジェクトでテストを改善
    import random
    import unittest.mock
    class TestKuji(unittest.TestCase):
    def test_win_if_is_lucky(self):
    random.choice = unittest.mock.Mock(return_value=True)
    actual = kuji.kuji()
    expected = 'あたり'
    self.assertEqual(expected, actual)
    return_valueでrandom.choice()が
    返す値をTrueに固定します
    このテストメソッドでは常に Trueが返
    され、
    安定したテストになります。
    テスト毎にランダムに値が出力される
    random.choiceをモックします

    View Slide

  35. unittest.mock.patch
    デコレータを使ったモックの表現
    @unittest.mock.patch('random.choice')
    def test_win_if_is_lucky(self, mock_choice):
    mock_choice.return_value=True
    actual = kuji.kuji()
    expected = 'あたり'
    self.assertEqual(expected, actual)
    このテストメソッド内では、
    random.choiceはmock_choiceという
    変数で利用されます。

    View Slide

  36. もっと役立つモックオブジェクト
    ● テストが失敗する外的要因を排除
    ○ テストを実行する時間
    ○ タイムゾーン
    ○ データベースの内容
    ○ ネットワークの状況
    ● とはいえ、モックオブジェクトに置き換えた箇所の動きは保証されていない
    のでよくテストされたものを使うなど注意が必要

    View Slide

  37. unittest.mock相談室
    1. unittest.mock.patchが当たらない
    2. 関数が呼ばれたことだけを確認したい
    3. datetime.datetime.nowをモックできない
    4. 外部APIを利用したテストをどうかけばいいのかわからない

    View Slide

  38. 1. unittest.mock.patchがあたらない
    src/sample.py
    from random import randint
    def get_num():
    return randint(1, 10)

    View Slide

  39. from … import … した関数が当たらない
    test_sample.py
    import src.sample
    class TestSample(unittest.TestCase):
    def test_get_num_from_import_fail(self):
    random.randint = unittest.mock.Mock(return_value=1)
    actual = src.sample.get_num_from_import()
    expected = 1
    self.assertEqual(actual, expected) randintをモックしたので
    1を返すはずなのに
    1にならない

    View Slide

  40. 秘密はfrom … import ...の仕組みにあった

    View Slide

  41. from … import ...を書いたモジュールに変更
    import src.sample
    class TestSample(unittest.TestCase):
    def test_get_num_from_import_fail(self):
    src.sample.randint = unittest.mock.Mock(return_value=1)
    actual = src.sample.get_num()
    expected = 1
    self.assertEqual(actual, expected)

    View Slide

  42. 2. 関数が呼ばれたことだけを確認したい
    sns.py
    import some_sns_module
    def send_share_sns(text: str, share: bool) -> None:
    """textに指定した文字列をとあるSNSでシェアします"""
    if share:
    some_sns_module.share(text) # 実際にSNSに通信してほしくない

    View Slide

  43. assert_called_once_withを使う
    assert_calledから始まる名前のテストメソッドを使うと、モックした関数が呼ば
    れたことを確認することが出来ます。
    import sns
    class TestSNS(unittest.TestCase):
    @unittest.mock.patch('some_sns_module.share')
    def test_share(self, mock_share):
    text = 'シェアしたい文字列'
    sns.send_share_sns(text, True)
    mock_share.assert_called_once_with(text)

    View Slide

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

    View Slide

  45. -> エラーになってテストできない
    test_greet.py
    class TestGreet(unittest.TestCase):
    def test_morning(self, mock_datetime):
    datetime.datetime.now = unittest.mock.Mock(
    return_value=datetime.datetime(
    2019, 10, 1, 5, 0, 0, 0
    )
    )
    expected = 'おはようございます'
    self.assertEqual(expected, src.greet.greet())

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  50. テスト対象の関数
    import http
    import requests
    def go_picnic(postal_code: str) -> True:
    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 True
    return False
    picnic.py

    View Slide

  51. 案① requests.getをモックする
    @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 = {
    '天気': '晴れ'
    }
    self.assertTrue(src.picnic.go_picnic1050004))

    View Slide

  52. 案②モックライブラリresponsesを使う
    import responses
    @responses.active
    def test_go_on_a_picnic_if_sunny(self):
    reponses.add(
    responses.GET,
    'https://tenki.example.com/today/1050004',
    json={'天気': '晴れ'},
    status=200)
    self.assertTrue(src.picnic.go_picnic(1050004))
    requestsを使っている場合のみ利用できます。

    View Slide

  53. まとめ
    ● 単体テストの目的はクラスや関数の振る舞いが意図通りかの確認
    ● テストコードで単体テストを自動化しましょう
    ● 単体テストでは一つのテストで一つのことだけテスト
    ● 「モック」とはオブジェクトや関数のふりをした偽物として振る舞うこと
    ● モックを使うことで関係ないところでテストが左右されないように
    ● Pythonでモックするならunittest.mock

    View Slide

  54. 参考文献
    ● テスト駆動開発
    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

  55. Special Thanks
    ● この発表の元ネタを話すきっかけを提供してくださった、Stapy2019年10月回の
    運営の皆様
    ● CfP書くのを手伝ってくださった、nikkieさん、naruseさん
    ● Pyhack冬合宿で発表練習に付き合ってくださった、 aodagさん、terapyonさん、
    drillerさん、tananoryさん、Jonasさん、OEさん、kananさん、usaturnさん、
    canzawaさん、hayaoさん、peacockさん、komo_frさん、tkoyamaさん

    View Slide

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

    View Slide