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

unittest.mockを使ってテストを書こう

mizzsugar
August 28, 2020

 unittest.mockを使ってテストを書こう

PyConJP2020の「unittest.mockを使ってテストを書こう」の発表資料です。

mizzsugar

August 28, 2020
Tweet

More Decks by mizzsugar

Other Decks in Programming

Transcript

  1. お前、誰よ • みずきと申します。 • Twitter : @mizzsugar0425 • PythonでWebサービスの開発をしています。 •

    コーヒーと自転車が好きです。 • 実はPyConの登壇は2回目。1回目は去年の飛び込みLT 「PyConJP2018で勇気をもらってPythonエンジニアになった話」 https://gihyo.jp/news/report/01/pyconjp2019/0002?page=4 • 今年は飛び込みじゃない登壇が出来て嬉しいです。 2
  2. 合計額を算出する関数をテスト 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 sum(item.price for item in items) 9
  3. テストコード class TestCalculate(unittest.TestCase): def test_tax_should_be_included_in_sum(self): item_1 = calculate.Item(name='ビール', price=500) item_2

    = calculate.Item(name='ワイン', price=1100) items = [item_1, item_2] actual = calculate.price(items) expected = 1600 self.assertEqual(expected, actual) 10
  4. テストコードがあると楽に安心して変更できる @dataclasses.dataclass(frozen=True) class Item: name: str price: int def price(

    items: Iterable[Item], discount_percentage: Optional[int] ) -> int: _sum = sum(item.price for item in items) if discount_percentage: discounted = (1 - discount_percentage / 100) return int(_sum * discounted) return _sum 割引クーポンが導入されて 割引率を表す引数が 追加された 11
  5. 割引クーポン導入に伴い、テストケース見直し class TestCalculate(unittest.TestCase): def test_calculate(self): # 中略 actual = src.calculate.price(items,

    discount_percentage=None) expected = 1600 self.assertEqual(expected, actual) def test_calculate_with_discount(self): # 中略 actual = src.calculate.price(items, discount_percentage=10) expected = 1440 self.assertEqual(expected, actual) 振る舞いの変更 に応じて テストメソッド名 も変更 割引なしの場合と 割引ありの場合の テストケースを作成 関数の呼び出し方も変更 12
  6. 14 def price(items: Iterable[Item], coupon: Optional[Coupon], date: datetime.date) -> int:

    _sum = int(sum(item.price for item in items)) if coupon: if not coupon.from_ <= date <= coupon.to: raise InvalidCouponError('このクーポンは使えません') discounted = (1 - discount_percentage / 100) return int(_sum * discounted) return _sum
  7. 手動でのテストだと・・・(読まなくて良いです) >>> import calculate >>> item1 = calculate.Item('ビール', 500) >>>

    item2 = calculate.Item('ワイン', 1100) >>> items = [item1, item2] >>> coupon = calculate.Coupon(from_=datetime.date(2020, 8, 1), to=datetime.date(2020, 8, 31), discount_percentage=10) >>> calculate.price(items, coupon=coupon, date=datetime.date(2020, 8, 29)) 1440 >>> 1600 * 1 - 10 / 100 1599.9 >>> # ↑()でくくるの忘れてた (本気でミスした) >>> int(1600 * (1 - 10 / 100)) 1440 >>> ... 15
  8. クーポンの有効期間導入に伴いテストケース見直し class TestCalculate(unittest.TestCase): # クーポンあり def test_calculate_with_validate_coupon(self): # 中略 coupon

    = calculate.Coupon( from_=datetime.date(2020, 8, 1), to=datetime.date(2020, 8, 31), discount_percentage=10 ) actual = calculate.price( items, coupon, date=datetime.date(2020, 8, 28) expected = 1440 self.assertEqual(expected, actual) 16
  9. # 有効期限切れのクーポン def test_raise_if_invalid_coupon(self): # 中略 self.assertRaises(calculate.InvalidCouponError): calculate.price( items, coupon,

    date=datetime.date(2020, 9, 1)) # クーポンなし def test_calculate_without_coupon(self): # 中略 actual = src.calculate.price( items, coupon=None, date=datetime.date(2020, 8, 28)) expected = 1600 self.assertEqual(expected, actual) 17
  10. こんな関数をテストしたいとします kuji.py import random def kuji() -> str: is_lucky =

    random.choice([True, False]) if is_lucky: return 'あたり' return 'はずれ' 24
  11. テストが通る時と通らない時がある事件発生 import app.kuji class TestKuji(unittest.TestCase): def test_win_if_True(self): actual = src.kuji.kuji()

    expected = 'あたり' self.assertEqual(expected, actual) def test_lose_if_False(self): actual = src.kuji.kuji() expected = 'はずれ' self.assertEqual(expected, actual) これじゃあ このテストが通れば安心と自 信をもてない!! 25
  12. なぜ不安定なテストになってしまったのか class TestKuji(unittest.TestCase): def test_win_if_True(self): actual = src.kuji.kuji() expected =

    'あたり' self.assertEqual(expected, actual) random.choiceの 実行結果がFalseになる 場合もある 26
  13. この発表における「モック」という言葉の扱い① • xUnitの文脈では、プログラムの中でオブジェクトや関数やモジュール(以下これら をまとめて「コンポーネント」と呼びます)の代品として動く仮のコンポーネントを「テ ストダブル」と呼びます。 • 「テストダブル」のうち、テスト対象物が利用するコンポーネントが テスト技術者が指定した通りに挙動するものを 「テストスタブ」と呼びます。 •

    「テストダブル」のうち、テスト対象物が利用するコンポーネントにどのようなアクセ スがあったか記録するものを「テストスパイ」と呼びます。 • xUnitにおける「モックオブジェクト」は、 コンポーネントへのアクセスを検証するのに利用しますが、 「テストスパイ」と異なり処理の途中で検証します。 30
  14. モックオブジェクトでテストを改善 import unittest.mock class TestKuji(unittest.TestCase): def test_win_if_is_lucky(self): import random random.choice

    = unittest.mock.Mock(return_value=True) actual = kuji.kuji() expected = 'あたり' self.assertEqual(expected, actual) return_valueでrandom.choice()が 返す値をTrueに固定します このテストメソッドでは常に Trueが返 され、 安定したテストになります。 テスト毎にランダムに値が出力される random.choiceをモックします 33
  15. インスタンスを置き換えることも可能 >>> class Person: ... def __init__(self, name): ... self.name

    = name ... ... def greet(self, word): ... return word >>> >>> p = Person(name='taro') >>> p.greet('hello') 'hello' >>> m = unittest.mock.Mock() >>> m.greet('hello') <Mock name='mock.greet()' id='139941090044080'> 34 p自体をモックオブ ジェクトにしたい
  16. side_effectで例外が発生するように指定 >>> m = unittest.mock.Mock() >>> m.error.side_effect = ValueError('これはエラーです') >>>

    m.error() ... ValueError: これはエラーです >>> p = Person(name='Taro') >>> p.greet = unittest.mock.Mock(side_effect=ValueError('これはエラーです ')) >>> p.greet('hi!') ... ValueError: これはエラーです 35
  17. 関数が呼ばれたことを記憶 >>> m.greet('hello') >>> m.greet.assert_called() # 呼ばれたので正常 >>> >>> m.greet.assert_not_called()

    # 呼ばれたので例外が発生 Traceback (most recent call last): File "<stdin>", line 1, in <module> File "/usr/lib/python3.8/unittest/mock.py", line 874, in assert_not_called raise AssertionError(msg) AssertionError: Expected 'greet' to not have been called. Called 1 times. >>> m.greet.assert_called_with('hello') # 引数に何を与えられたのかを記憶 36
  18. 引数を確認するならcall_args 37 >>> m.greet('hello') >>> m.greet.call_args # 引数を確認 call('hello') >>>

    m.greet.call_args[0] # 位置引数をタプルで確認 ('hello',) >>> m.greet.call_args[0][0] # 1番目の位置引数を確認 hello
  19. キーワード引数を確認 38 >>> m.greet(text='hello') >>> m.greet.call_args # 引数を確認 call(text='hello') >>>

    m.greet.call_args[1] # キーワード引数を辞書で確認 {'text': 'hello'} >>> m.greet.call_args.kwargs # kwargsでもキーワード引数を確認可 {'text': 'hello'} >>> m.greet.call_args.kwargs['text'] >>> hello
  20. 位置引数とキーワード引数両方使っている場合 39 >>> m.greet('hello', date=datetime.date(2020, 8, 28)) >>> m.greet.call_args #

    引数を確認 call('hello', date=datetime.date(2020, 8, 28)) >>> m.greet.call_args[0] # 位置引数を確認 ('hello',) >>> m.greet.call_args[1] # キーワード引数を確認 {'text': 'hello'} >>> m.greet.call_args.kwargs # キーワード引数を確認 {'text': 'hello'}
  21. call_args_listで呼び出された順番を確認 40 >>> m.greet('good morning') >>> m.greet('hello') >>> m.greet('good night')

    >>> m.greet.call_args_list [call('good morning'), call('hello'), call('good night')] expected = [(('good morning',), ),(('hello',), ),(('good night',), )] >>> m.greet.call_args_list == expected True
  22. 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という 変数で利用されます。 'package.module.ClassName' の形式にします。 42
  23. MockクラスのインスタンスはCallable① • Callableとは、関数呼び出し出来るオブジェクトのこと >>> def f() -> str: ... return

    'This is Callable.' ... >>> >>> f # これはCallable <function f at 0x7f9b2bdab670> >>> f() # これはstr 'this is Callable' 47
  24. MockクラスのインスタンスはCallable② 48 >>> import unittest.mock.Mock >>> m = unittest.mock.Mock(return_value=3) #

    関数呼び出しした時の返 り値を指定 >>> m <Mock id='140304410490000'> >>> m() 3 >>> thing = ProductionClass() >>> thing.method = unittest.mock.Mock(return_value=3) >>> thing.method # これはCallable <MagicMock id='140304410551584'> >>> thing.method() # これはint 3
  25. MockとMagicMockの違い③ Mockでは出来ません。 51 >>> m = unittest.mock.Mock() >>> m.__str__.return_value =

    'aaa' Traceback (most recent call last): File "<stdin>", line 1, in <module> AttributeError: 'method-wrapper' object has no attribute 'return_value'
  26. MockとMagicMockの違い④ magic method を mock するには、対象の method に対して関数や mock のインスタン

    スをセットします。もし関数を使う場合、それは第一引数に self を取る 必要があります。  https://docs.python.org/ja/3/library/unittest.mock.html#mocking-magic-methods 52 >>> def __str__(self): ... return 'a' ... >>> m = unittest.mock.Mock() >>> m.__str__ = __str__ >>> str(m) 'a'
  27. MockとMagicMockの違い⑤ magic method は通常のメソッドとはルックアップ方法が異なるので 2, magic method のサポートは特別に実装されています。そのため、サポートされているのは特定の magic method

    のみです。 Magic method はインスタンスではなくクラスからルックアップされるはずです。Python のバージョンによってこのルールが適用されるかどうかに違いがあります。サポートされ ているプロトコルメソッドは、サポートされているすべての Python のバージョンで動作す るはずです。 ※Python公式ドキュメントから引用。 色と強調はドキュメントとは別に自分でつけました。 53 https://docs.python.org/ja/3/library/unittest.mock.html#magicmock-and-magic-method-support
  28. 単体テストは通るが静的解析は通らない例② class TestDownloadApplicantsOfDateCsv: def test_download_applicants_of_date_csv(self): date = datetime.date(2020, 8, 28)

    app.list_applicants = unittest.mock.MagicMock() app.list_applicants.return_value = iter([app.User(name='py')]) actual = app.download_applicants_of_date_csv(date) ... 59
  29. 単体テストは通るが静的解析は通らない例④ class TestDownloadApplicantsOfDateCsv: def test_download_applicants_of_date_csv(self): date = datetime.date(2020, 8, 28)

    app.list_applicants = unittest.mock.MagicMock() app.list_applicants.return_value = iter([app.User(name='py')]) actual = app.download_applicants_of_date_csv(date) ... 61 このテストは通りましたが 実行時にエラーになりました。
  30. 型ヒントで静的解析してバグを防ごう① def list_applicants(date_range: Tuple[datetime.date, datetime.date]) -> Iterator[User]: """指定した期間に申し込んだユーザー一覧を取得します。 """ ...

    def download_applicants_of_date_csv(date: datetime.date) -> IO[str]: for user in list_applicants(date): ... 62 app.py こうやって 型ヒントを書いて…
  31. 型ヒントで静的解析してバグを防ごう② >>> pip install mypy >>> mypy app.py app.py:23: error:

    Argument 1 to "list_applicants" has incompatible type "date"; expected "Tuple[datetme.date, datetime.date]" 63 download_applicants_of_date_csv内で 使っているlist_applicantsが 意図しない型の引数を受け取っているので 注意してくれます。
  32. mypyから指摘を受けてテストコード修正 class TestDownloadApplicantsOfDateCsv: def test_download_applicants_of_date_csv(self): date = datetime.date(2020, 8, 28)

    app.list_applicants = unittest.mock.MagicMock() app.list_applicants.return_value = iter([app.User(name='py')]) actual = app.download_applicants_of_date_csv(date) expected_date_range = tuple(date, date) app.list_applicants.assert_called_with(expected_date_range) 65
  33. 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にならない 68
  34. 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) 70
  35. 2. 関数を実際に実行してほしくない sns.py import some_sns_module def send_share_sns(text: str) -> None:

    """textに指定した文字列をとあるSNSでシェアします""" some_sns_module.share(text) # 実際にSNSに通信してほしくない 71
  36. 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 'こんばんは' 73
  37. -> エラーになってテストできない test_greet.py class TestGreet(unittest.TestCase): def test_morning(self, mock_datetime): datetime.datetime.now =

    unittest.mock.Mock( return_value=datetime.datetime( 2020, 8, 28, 11, 50, 0, 0 ) ) expected = 'おはようございます' self.assertEqual(expected, src.greet.greet()) 74
  38. 案1: ライブラリ「freezegun」を使う test_greet.py from freezegun import freeze_time class TestGreet(unittest.TestCase): @freeze_time('2020-8-28

    11:50:00') def test_morning(self): expected = 'おはようございます' self.assertEqual(expected, src.greet.greet()) 75 https://github.com/spulec/freezegun
  39. 案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(2020, 8, 28, 11, 50, 0, 0) ) ) 76
  40. テスト対象の関数 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()['天気'] == '晴れ': return True return False picnic.py 79
  41. 案① 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=http.httpStatus.OK

    ) # 他の項目も入れるべきかは後続の処理によります。 mock_request.return_value.json.return_value = { '天気': '晴れ' } self.assertTrue(src.picnic.go_picnic(1050004)) 80
  42. 案②モックライブラリresponsesを使う import responses class TestPicnic: @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を使っている場合のみ利用できます。 81