Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

mizzsugar
February 29, 2020

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

mizzsugar

February 29, 2020
Tweet

More Decks by mizzsugar

Other Decks in Programming

Transcript

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

    GCP, BigQuery • 趣味はPythonでWeb開発です。 ◦ Django, Pyramid, Nuxt.js, TypeScript, PostgreSQL • Djangogirlsコーチやってます。 • コーヒーと自転車が好きです。
  2. 前提 • unittest.mock特有の話ではないので、pytestでも使えます。 • 単体テストのことを話します。 • E2Eテストやシステムテストや結合テストについては話しません。 • テスト駆動開発については話しません。 •

    スライド内に収めるためにサンプルコードがPEP8に反していることがありま すがご了承ください。 • 議論は大歓迎です! ただし、強い言葉や否定の言葉のない優しい世界 でお願いします(>人<)
  3. 合計額を算出する関数をテストしよう 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)
  4. テストコードを書いてみよう① 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クラスを継承したクラスを作成。
  5. テストコードを書いてみよう② 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_から始まらないと実行されません。
  6. テストコードがあると楽に安心して変更できる ※今回の例はわかりやすさを重視して厳密な軽減税率のルールには則っていません。 @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) 軽減税率が導入されて イートインか判定する引数が 追加された
  7. 軽減税率導入に伴い、テストケース見直し 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) 振る舞いの変更 に応じて テストメソッド名 も変更 イートインの場合と テイクアウトの場合の テストケースを作成 関数の呼び出し方も変更
  8. 手動でのテストだと・・・(読まなくて良いです) >>> 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
  9. キャッシュレス導入に伴い、テストケース見直し 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)
  10. 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)
  11. こんな関数をテストしたいとします kuji.py import random def kuji() -> str: is_lucky =

    random.choice([True, False]) if is_lucky: return 'あたり' return 'はずれ'
  12. テストが通る時と通らない時がある事件発生 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) これじゃあ このテストが通れば安心と自 信をもちきれない!!
  13. なぜ不安定なテストになってしまったのか class TestKuji(unittest.TestCase): def test_win_if_multiple_of_2(self): actual = src.kuji.kuji() expected =

    'あたり' self.assertEqual(expected, actual) random.choiceが 実行されているため、 Falseが利用されることもあ る。
  14. モックオブジェクトでテストを改善 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をモックします
  15. 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という 変数で利用されます。
  16. もっと役立つモックオブジェクト • テストが失敗する外的要因を排除 ◦ テストを実行する時間 ◦ タイムゾーン ◦ データベースの内容 ◦

    ネットワークの状況 • とはいえ、モックオブジェクトに置き換えた箇所の動きは保証されていない のでよくテストされたものを使うなど注意が必要
  17. 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にならない
  18. 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)
  19. 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に通信してほしくない
  20. 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 'こんばんは'
  21. -> エラーになってテストできない 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())
  22. 案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())
  23. 案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) ) )
  24. テスト対象の関数 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
  25. 案① 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))