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

C98d379da6e5517afff697a6c5615e68?s=47 mizzsugar
February 29, 2020

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

C98d379da6e5517afff697a6c5615e68?s=128

mizzsugar

February 29, 2020
Tweet

Transcript

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

  2. お前、誰よ • みずきと申します。 • Twitter : @mizzsugar0425 • 仕事はデータ分析基盤の開発・運用です。 ◦

    GCP, BigQuery • 趣味はPythonでWeb開発です。 ◦ Django, Pyramid, Nuxt.js, TypeScript, PostgreSQL • Djangogirlsコーチやってます。 • コーヒーと自転車が好きです。
  3. このトークの対象者 • unittest.mockの存在は知っているけれどもイマイチ使いどころが分かって いない人 • assertTrueやassertEqualなどの基本的なテストコードの文法は知ってい るけれど、何のためにテストを書くかをじっくり考えたことがない人

  4. 前提 • unittest.mock特有の話ではないので、pytestでも使えます。 • 単体テストのことを話します。 • E2Eテストやシステムテストや結合テストについては話しません。 • テスト駆動開発については話しません。 •

    スライド内に収めるためにサンプルコードがPEP8に反していることがありま すがご了承ください。 • 議論は大歓迎です! ただし、強い言葉や否定の言葉のない優しい世界 でお願いします(>人<)
  5. 本日の発表のソースコード https://github.com/mizzsugar/pycon_mini_shizuoka_2020

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

  7. 前半

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

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

    ◦ 振る舞いを変更した場合に正しく振る舞えるかコマンド一つで確認でき ます
  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)
  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クラスを継承したクラスを作成。
  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_から始まらないと実行されません。
  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)
  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) 軽減税率が導入されて イートインか判定する引数が 追加された
  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) 振る舞いの変更 に応じて テストメソッド名 も変更 イートインの場合と テイクアウトの場合の テストケースを作成 関数の呼び出し方も変更
  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)
  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
  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)
  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)
  20. テストコードがないと・・・ • 変更があるたびに手動で振る舞いを確認しないといけません • どこまで検証したのか第三者に分かりづらいです テストコードがあると・・・ • コマンド一つで振る舞いを確認できます • テストケースの内容をみるとどう検証したか確認できます

    テストコードで単体テストを自動化しよう
  21. 一息つきます

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

  23. 後半

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

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

  26. 例えば・・・

  27. こんな関数をテストしたいとします kuji.py import random def kuji() -> str: is_lucky =

    random.choice([True, False]) if is_lucky: return 'あたり' return 'はずれ'
  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) これじゃあ このテストが通れば安心と自 信をもちきれない!!
  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が利用されることもあ る。
  30. 1つのことだけ確認したい • 標準モジュールで保証されているrandom.choice自体の動きまで確認したくありま せん • random.cohiceの出力によって「あたり」が出るか「はずれ」が出るかが大事

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

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

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

  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をモックします
  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という 変数で利用されます。
  36. もっと役立つモックオブジェクト • テストが失敗する外的要因を排除 ◦ テストを実行する時間 ◦ タイムゾーン ◦ データベースの内容 ◦

    ネットワークの状況 • とはいえ、モックオブジェクトに置き換えた箇所の動きは保証されていない のでよくテストされたものを使うなど注意が必要
  37. unittest.mock相談室 1. unittest.mock.patchが当たらない 2. 関数が呼ばれたことだけを確認したい 3. datetime.datetime.nowをモックできない 4. 外部APIを利用したテストをどうかけばいいのかわからない

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

    randint(1, 10)
  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にならない
  40. 秘密はfrom … import ...の仕組みにあった

  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)
  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に通信してほしくない
  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)
  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 'こんばんは'
  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())
  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())
  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) ) )
  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 'こんばんは'
  49. 外部APIを利用した関数のテストが書けない 日本の天気を取得するAPIを利用してピクニックの実施を判定する関数をテストするとし ます。 https://tenki.example.com/today/[郵便番号(7桁の半角数字ハイフンなし)] をGETでリクエストを送ると下記のようなJSONが返されます。 存在しない郵便番号を指定してリクエストが送られたらHTTPステータスコードが404でレ スポンスが返されます。 ※これは架空のAPIであり、実際には使用できません { '郵便番号':

    '1050004' '都道府県': '東京都', '市区町村': '港区新橋', '天気': '晴れ' }
  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
  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))
  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を使っている場合のみ利用できます。
  53. まとめ • 単体テストの目的はクラスや関数の振る舞いが意図通りかの確認 • テストコードで単体テストを自動化しましょう • 単体テストでは一つのテストで一つのことだけテスト • 「モック」とはオブジェクトや関数のふりをした偽物として振る舞うこと •

    モックを使うことで関係ないところでテストが左右されないように • Pythonでモックするならunittest.mock
  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
  55. Special Thanks • この発表の元ネタを話すきっかけを提供してくださった、Stapy2019年10月回の 運営の皆様 • CfP書くのを手伝ってくださった、nikkieさん、naruseさん • Pyhack冬合宿で発表練習に付き合ってくださった、 aodagさん、terapyonさん、 drillerさん、tananoryさん、Jonasさん、OEさん、kananさん、usaturnさん、

    canzawaさん、hayaoさん、peacockさん、komo_frさん、tkoyamaさん
  56. ありがとうございました!