Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

お前、誰よ ● みずきと申します。 ● Twitter : @mizzsugar0425 ● 仕事はデータ分析基盤の開発・運用です。 ○ GCP, BigQuery ● 趣味はPythonでWeb開発です。 ○ Django, Pyramid, Nuxt.js, TypeScript, PostgreSQL ● Djangogirlsコーチやってます。 ● コーヒーと自転車が好きです。

Slide 3

Slide 3 text

このトークの対象者 ● unittest.mockの存在は知っているけれどもイマイチ使いどころが分かって いない人 ● assertTrueやassertEqualなどの基本的なテストコードの文法は知ってい るけれど、何のためにテストを書くかをじっくり考えたことがない人

Slide 4

Slide 4 text

前提 ● unittest.mock特有の話ではないので、pytestでも使えます。 ● 単体テストのことを話します。 ● E2Eテストやシステムテストや結合テストについては話しません。 ● テスト駆動開発については話しません。 ● スライド内に収めるためにサンプルコードがPEP8に反していることがありま すがご了承ください。 ● 議論は大歓迎です! ただし、強い言葉や否定の言葉のない優しい世界 でお願いします(>人<)

Slide 5

Slide 5 text

本日の発表のソースコード https://github.com/mizzsugar/pycon_mini_shizuoka_2020

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

前半

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 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 int(sum(item.price for item in items) * 1.08)

Slide 11

Slide 11 text

テストコードを書いてみよう① 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クラスを継承したクラスを作成。

Slide 12

Slide 12 text

テストコードを書いてみよう② 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_から始まらないと実行されません。

Slide 13

Slide 13 text

テストコードを書いてみよう③ 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)

Slide 14

Slide 14 text

テストコードがあると楽に安心して変更できる ※今回の例はわかりやすさを重視して厳密な軽減税率のルールには則っていません。 @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) 軽減税率が導入されて イートインか判定する引数が 追加された

Slide 15

Slide 15 text

軽減税率導入に伴い、テストケース見直し 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) 振る舞いの変更 に応じて テストメソッド名 も変更 イートインの場合と テイクアウトの場合の テストケースを作成 関数の呼び出し方も変更

Slide 16

Slide 16 text

キャッシュレスなら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)

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

キャッシュレス導入に伴い、テストケース見直し 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)

Slide 19

Slide 19 text

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)

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

一息つきます

Slide 22

Slide 22 text

モックの話に移ります

Slide 23

Slide 23 text

後半

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

例えば・・・

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

テストが通る時と通らない時がある事件発生 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) これじゃあ このテストが通れば安心と自 信をもちきれない!!

Slide 29

Slide 29 text

なぜ不安定なテストになってしまったのか class TestKuji(unittest.TestCase): def test_win_if_multiple_of_2(self): actual = src.kuji.kuji() expected = 'あたり' self.assertEqual(expected, actual) random.choiceが 実行されているため、 Falseが利用されることもあ る。

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 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という 変数で利用されます。

Slide 36

Slide 36 text

もっと役立つモックオブジェクト ● テストが失敗する外的要因を排除 ○ テストを実行する時間 ○ タイムゾーン ○ データベースの内容 ○ ネットワークの状況 ● とはいえ、モックオブジェクトに置き換えた箇所の動きは保証されていない のでよくテストされたものを使うなど注意が必要

Slide 37

Slide 37 text

unittest.mock相談室 1. unittest.mock.patchが当たらない 2. 関数が呼ばれたことだけを確認したい 3. datetime.datetime.nowをモックできない 4. 外部APIを利用したテストをどうかけばいいのかわからない

Slide 38

Slide 38 text

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

Slide 39

Slide 39 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にならない

Slide 40

Slide 40 text

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

Slide 41

Slide 41 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)

Slide 42

Slide 42 text

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に通信してほしくない

Slide 43

Slide 43 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, True) mock_share.assert_called_once_with(text)

Slide 44

Slide 44 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 'こんばんは'

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

案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())

Slide 47

Slide 47 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(2019, 10, 1, 5, 0, 0, 0) ) )

Slide 48

Slide 48 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 'こんばんは'

Slide 49

Slide 49 text

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

Slide 50

Slide 50 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().get('天気') == '晴れ': return True return False picnic.py

Slide 51

Slide 51 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=200 ) # 他の項目も入れるべきかは後続の処理によります。 mock_request.return_value.json.return_value = { '天気': '晴れ' } self.assertTrue(src.picnic.go_picnic1050004))

Slide 52

Slide 52 text

案②モックライブラリ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を使っている場合のみ利用できます。

Slide 53

Slide 53 text

まとめ ● 単体テストの目的はクラスや関数の振る舞いが意図通りかの確認 ● テストコードで単体テストを自動化しましょう ● 単体テストでは一つのテストで一つのことだけテスト ● 「モック」とはオブジェクトや関数のふりをした偽物として振る舞うこと ● モックを使うことで関係ないところでテストが左右されないように ● Pythonでモックするならunittest.mock

Slide 54

Slide 54 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

Slide 55

Slide 55 text

Special Thanks ● この発表の元ネタを話すきっかけを提供してくださった、Stapy2019年10月回の 運営の皆様 ● CfP書くのを手伝ってくださった、nikkieさん、naruseさん ● Pyhack冬合宿で発表練習に付き合ってくださった、 aodagさん、terapyonさん、 drillerさん、tananoryさん、Jonasさん、OEさん、kananさん、usaturnさん、 canzawaさん、hayaoさん、peacockさん、komo_frさん、tkoyamaさん

Slide 56

Slide 56 text

ありがとうございました!