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

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

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.
Avatar for mizzsugar mizzsugar
August 28, 2020

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

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

Avatar for mizzsugar

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