Slide 1

Slide 1 text

unittest.mockを使って
 テストを書こう
 ~モックオブジェクトを使ってより効率的で安定したテストに~ 2020/08/28 PyConJP2020 @mizzsugar0425 1

Slide 2

Slide 2 text

お前、誰よ ● みずきと申します。 ● Twitter : @mizzsugar0425 ● PythonでWebサービスの開発をしています。 ● コーヒーと自転車が好きです。 ● 実はPyConの登壇は2回目。1回目は去年の飛び込みLT 「PyConJP2018で勇気をもらってPythonエンジニアになった話」 https://gihyo.jp/news/report/01/pyconjp2019/0002?page=4 ● 今年は飛び込みじゃない登壇が出来て嬉しいです。 2

Slide 3

Slide 3 text

このトークの対象者 ● unittest.mockの存在は知っているけれどもイマイチ使いどころが分からな い人 3

Slide 4

Slide 4 text

前提 ● unittest特有の話ではないので、pytestなど他のテストライブラリでも使え ます。 ● 単体テストのことを話します。 ● E2Eテストやシステムテストや結合テストについては話しません。 ● テスト駆動開発については話しません。 ● スライド内に収めるためにサンプルコードがPEP8に反していることがありま すがご了承ください。 4

Slide 5

Slide 5 text

このトークは3部に分かれます 1部 単体テストの目的 2部 単体テストでのunittest.mockの使い方 3部 unitest.mock Tips&アンチパターン 5

Slide 6

Slide 6 text

1部 単体テストの目的 6

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

合計額を算出する関数をテスト 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

Slide 10

Slide 10 text

テストコード 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

Slide 11

Slide 11 text

テストコードがあると楽に安心して変更できる @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

Slide 12

Slide 12 text

割引クーポン導入に伴い、テストケース見直し 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

Slide 13

Slide 13 text

割引クーポンの有効期間が適用されるように @dataclasses.dataclass(frozen=True) class Coupon: from_: datetime.date to: datetime.date discount_percentage: int class Error(Exception): pass class InvalidCouponError(Error): pass 13

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

手動でのテストだと・・・(読まなくて良いです) >>> 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

Slide 16

Slide 16 text

クーポンの有効期間導入に伴いテストケース見直し 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

Slide 17

Slide 17 text

# 有効期限切れのクーポン 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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

モックの話に移ります 19

Slide 20

Slide 20 text

2部 単体テストでのunittest.mockの使い方 20

Slide 21

Slide 21 text

今日これだけは持ち帰ってほしい(2部) ● 「モック」とはオブジェクトや関数のふりをした偽物として振る舞うこと ● モックを使うことで関係ないところでテストが左右されないようになります。 ● MockとMagicMockならMagicMockを使いましょう。 21

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

例えば・・・ 23

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

テストが通る時と通らない時がある事件発生 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

Slide 26

Slide 26 text

なぜ不安定なテストになってしまったのか class TestKuji(unittest.TestCase): def test_win_if_True(self): actual = src.kuji.kuji() expected = 'あたり' self.assertEqual(expected, actual) random.choiceの 実行結果がFalseになる 場合もある 26

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

関係ないものはモックしよう 28

Slide 29

Slide 29 text

関係ないものはモックしよう ● 「モック」という言葉には「偽の」「まがいの」という意味があります。 ● プログラム内のオブジェクトや関数のふりをした偽物として振る舞うイメージ ● Pythonでは、標準ライブラリ「unittest.mock」を使って、プログラム内の関数やオブ ジェクト(ここではまとめてコンポーネントと呼びます)を 仮のコンポーネントに置き換えます。 ● テスト技術者は仮のコンポーネントの挙動を指定することが出来ます。 ● 仮のコンポーネントに置き換えることで構築に手間がかかるオブジェクトを 高速に利用できます。 29

Slide 30

Slide 30 text

この発表における「モック」という言葉の扱い① ● xUnitの文脈では、プログラムの中でオブジェクトや関数やモジュール(以下これら をまとめて「コンポーネント」と呼びます)の代品として動く仮のコンポーネントを「テ ストダブル」と呼びます。 ● 「テストダブル」のうち、テスト対象物が利用するコンポーネントが テスト技術者が指定した通りに挙動するものを 「テストスタブ」と呼びます。 ● 「テストダブル」のうち、テスト対象物が利用するコンポーネントにどのようなアクセ スがあったか記録するものを「テストスパイ」と呼びます。 ● xUnitにおける「モックオブジェクト」は、 コンポーネントへのアクセスを検証するのに利用しますが、 「テストスパイ」と異なり処理の途中で検証します。 30

Slide 31

Slide 31 text

この発表における「モック」という言葉の扱い② 31

Slide 32

Slide 32 text

この発表における「モック」という言葉の扱い③ ● unittest.mockでは、テストスタブとテストスパイ両方出来ます。 ● unitest.mock.Mockクラスのインスタンスが両方を担います。 ● この発表では、xUnitの文脈で「テストダブル」と呼んでいるものをまとめて「モックオ ブジェクト」と言います。 ● この発表では、オブジェクトや関数やモジュールを xUnitでいうテストダブルに置き換えることを「モックする」と言います。 32

Slide 33

Slide 33 text

モックオブジェクトでテストを改善 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

Slide 34

Slide 34 text

インスタンスを置き換えることも可能 >>> 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') 34 p自体をモックオブ ジェクトにしたい

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

関数が呼ばれたことを記憶 >>> m.greet('hello') >>> m.greet.assert_called() # 呼ばれたので正常 >>> >>> m.greet.assert_not_called() # 呼ばれたので例外が発生 Traceback (most recent call last): File "", line 1, in 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

Slide 37

Slide 37 text

引数を確認するなら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

Slide 38

Slide 38 text

キーワード引数を確認 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

Slide 39

Slide 39 text

位置引数とキーワード引数両方使っている場合 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'}

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

call_countで呼び出された回数を確認 41 >>> m.greet('good morning') >>> m.greet('hello') >>> m.greet('good night') >>> m.greet.call_count 3

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

モックオブジェクトを使って嬉しいこと ● テストが失敗する外的要因を排除 ○ テストを実行する時間 ○ タイムゾーン ○ データベースの内容 ○ ネットワークの状況 43

Slide 44

Slide 44 text

休憩 44

Slide 45

Slide 45 text

休憩中にやること ● 水分とりましょう ● 深呼吸して落ち着きましょう ● 折返し地点まで来ました。残り時間を確認しましょ う ● 気合入れて再スタートしましょう! 45

Slide 46

Slide 46 text

unittest.mock.Mockクラスの仕組み 46

Slide 47

Slide 47 text

MockクラスのインスタンスはCallable① ● Callableとは、関数呼び出し出来るオブジェクトのこと >>> def f() -> str: ... return 'This is Callable.' ... >>> >>> f # これはCallable >>> f() # これはstr 'this is Callable' 47

Slide 48

Slide 48 text

MockクラスのインスタンスはCallable② 48 >>> import unittest.mock.Mock >>> m = unittest.mock.Mock(return_value=3) # 関数呼び出しした時の返 り値を指定 >>> m >>> m() 3 >>> thing = ProductionClass() >>> thing.method = unittest.mock.Mock(return_value=3) >>> thing.method # これはCallable >>> thing.method() # これはint 3

Slide 49

Slide 49 text

MockとMagicMockの違い① ● MagicMockはMockの上位互換 ● 基本的な使い方は同じ ● MockはPythonのマジックメソッドが実装されていない ● MagicMockはほぼ全てのマジックメソッドが実装されている ● MagicMockを使った方が便利 49

Slide 50

Slide 50 text

MockとMagicMockの違い② MagicMockでは 通常のメソッドと同じようにマジックメソッドをモックできます。 50 >>> m = unittest.mock.MagicMock() >>> m.__str__.return_value = 'aa' >>> str(m) aa

Slide 51

Slide 51 text

MockとMagicMockの違い③ Mockでは出来ません。 51 >>> m = unittest.mock.Mock() >>> m.__str__.return_value = 'aaa' Traceback (most recent call last): File "", line 1, in AttributeError: 'method-wrapper' object has no attribute 'return_value'

Slide 52

Slide 52 text

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'

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

3部 unittest.mock Tips&アンチパターン 54

Slide 55

Slide 55 text

unittest.mockアンチパターン ● モックオブジェクトに置き換えている所をテストしていない ● ソースコードを変更する際にモックオブジェクトの使い方を見直さない 55

Slide 56

Slide 56 text

置き換えている箇所をテストしていない ● よくテストしている箇所、もしくはテストが不要なほど細分化された箇所のみをモック オブジェクトに置き換えるべきです。 ● 標準ライブラリをテストするようなものは逆にテスト不要です。 56 def dump_dict(source): json.dumps(source).encode() テスト不要な関数の例

Slide 57

Slide 57 text

モックオブジェクトの使い方を見直さない ● ソースコードを変更する際に、それに関わるテストで使っているモックオブジェクトの 使い方を見直さないのはNGです。 ● レビューで注意してチェックしましょう。 ● mypyやflake8を使った静的解析で変更の漏れを防ぎましょう。 57

Slide 58

Slide 58 text

単体テストは通るが静的解析は通らない例① def list_applicants(date): """指定した日に申し込んだユーザー一覧を取得します。 """ ... def download_applicants_of_date_csv(date): for user in list_applicants(date): ... 58 この時、dateはdatetime.date型を使う想定 この時、dateはdatetime.date型を使う想定 app.py

Slide 59

Slide 59 text

単体テストは通るが静的解析は通らない例② 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

Slide 60

Slide 60 text

単体テストは通るが静的解析は通らない例③ def list_applicants(date_range): """指定した期間に申し込んだユーザー一覧を取得します。 """ ... def download_applicants_of_date_csv(date): for user in list_applicants(date): ... 60 期間なので Tuple[datetime.date, datetime.date] に変更 この時、dateはdatetime.date型のまま app.py

Slide 61

Slide 61 text

単体テストは通るが静的解析は通らない例④ 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 このテストは通りましたが 実行時にエラーになりました。

Slide 62

Slide 62 text

型ヒントで静的解析してバグを防ごう① 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 こうやって 型ヒントを書いて…

Slide 63

Slide 63 text

型ヒントで静的解析してバグを防ごう② >>> 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が 意図しない型の引数を受け取っているので 注意してくれます。

Slide 64

Slide 64 text

mypyから指摘を受けてプロダクトコード修正 64 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(tuple(date, date)): ... app.py

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

unittest.mock Tips 1. unittest.mock.patchが当たらない 2. 関数を実際に実行してほしくない 3. datetime.datetime.nowをモックできない 4. 外部APIを利用したテストをどうかけばいいのかわからない 66

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

2. 関数を実際に実行してほしくない sns.py import some_sns_module def send_share_sns(text: str) -> None: """textに指定した文字列をとあるSNSでシェアします""" some_sns_module.share(text) # 実際にSNSに通信してほしくない 71

Slide 72

Slide 72 text

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) mock_share.assert_called_once_with(text) 72

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

-> エラーになってテストできない 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

Slide 75

Slide 75 text

案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

Slide 76

Slide 76 text

案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

Slide 77

Slide 77 text

時間を引数にもたせると 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 'こんばんは' 77

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

テスト対象の関数 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

Slide 80

Slide 80 text

案① 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

Slide 81

Slide 81 text

案②モックライブラリ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

Slide 82

Slide 82 text

まとめ ● 単体テストの目的はクラスや関数の振る舞いが意図通りかの確認 ● 単体テストでは一つのテストで一つのことだけテスト ● 「モック」とはオブジェクトや関数のふりをした偽物として振る舞うこと ● モックすることで関係ないところでテストが左右されないように ● unittest.mockではモックした関数の振る舞いを指定できます ● unittest.mockではモックした関数がどのように呼ばれたのか確認できます 82

Slide 83

Slide 83 text

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