Slide 1

Slide 1 text

過去の私に伝えたい、 Pythonのunittest.mockの あれこれ 2019/10/09 みんなのPython勉強会 @mizzsugar0425

Slide 2

Slide 2 text

お前、誰よ ● Twitter : @mizzsugar0425 ● PythonでWeb開発しています。 ○ 仕事:Django, Vue.js, MySQL ○ 趣味:Pyramid, Nuxt.js, TypeScript, PostgreSQL ● Djangogirlsコーチやってます。先日、翻訳デビューしました! ● コーヒーと自転車が好きです。

Slide 3

Slide 3 text

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

Slide 4

Slide 4 text

単体テストって? ● 単体テストの目的 ○ プロダクトコードが意図した通りに動くことを確認する ● 単体テストを書いたら嬉しいこと ○ いつでも同じテストができるので不安なくリファクタリングできる ○ 振る舞いを変更した場合に正しく振る舞えるかすばやく確認できる ○ テストを書くことでテスト対象となる関数やライブラリの最初のユーザーとなる。それによって それら が使いやすいかを判断できる。

Slide 5

Slide 5 text

こういう時に単体テスト書いていると嬉しい calculate.py 飲食店で注文した商品の合計に消費税を加えて購入金額を計算する関数です。 この関数では金額の合計に 1.08をかけるだけですが・・・ ※今回の例はわかりやすさを重視して厳密な軽減税率のルールには則っていません。 import dataclasses from typing import Iterable @dataclasses.dataclass(frozen=True) class Item: name: str price: int def price(items: Iterable[Item]) -> int: # 簡単にするためにひとまず int()で端数処理します return int(sum(item.price for item in items) * 1.08)

Slide 6

Slide 6 text

軽減税率が導入されるようになった calculate.py import dataclasses from typing import Iterable @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 7

Slide 7 text

キャッシュレスなら5%減額されるようになった calculate.py この後に更にルールの追加、変更・・・ となると 手動で関数の振る舞いが正しいかを確認するのが大変です。 import dataclasses from typing import Iterable @dataclasses.dataclass(frozen=True) class Item: name: str price: int def price(items: Iterable[Item], eat_in: bool, cache_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 8

Slide 8 text

単体テストが書いていると import unittest import src.calculate class TestCalculate(unittest.TestCase): def setUp(self): self.item_1 = src.calculate.Item(name='sample_1', price=500) self.item_2 = src.calculate.Item(name='sample_2', price=1000) def test_calculate_sum(self): items = [self.item_1, self.item_2] actual = src.calculate.price(items) expected = 1620 self.assertEqual(expected, actual)

Slide 9

Slide 9 text

軽減税率導入に伴い、テストケース見直し import unittest import src.calculate class TestCalculate(unittest.TestCase): """中略""" def test_calculate_sum_eatin(self): items = [self.item_1, self.item_2] actual = src.calculate.price(items, eat_in=False) expected = 1620 self.assertEqual(expected, actual) def test_calculate_takeout(self): items = [self.item_1, self.item_2] actual = src.calculate.price(items, eat_in=True) expected = 1650 self.assertEqual(expected, actual) 振る舞いの変更 に応じて テストメソッド名 も変更 イートインの場合と テイクアウトの場合の テストケースを作成

Slide 10

Slide 10 text

キャッシュレス導入に伴い、テストケース見直し class TestCalculate(unittest.TestCase): """中略""" def test_calculate_sum_eatin_cache(self): items = [self.item_1, self.item_2] actual = src.calculate.price(items, eat_in=False, cache_less=False) expected = 1620 self.assertEqual(expected, actual) def test_calculate_takeout_cache(self): items = [self.item_1, self.item_2] actual = src.calculate.price(items, eat_in=True, cache_less=False) expected = 1650 self.assertEqual(expected, actual) def test_calculate_sum_eatin_cache_less(self): items = [self.item_1, self.item_2] actual = src.calculate.price(items, eat_in=False, cache_less=True) expected = 1539 self.assertEqual(expected, actual) def test_calculate_takeout_cache_less(self): items = [self.item_1, self.item_2] actual = src.calculate.price(items, eat_in=True, cache_less=True) expected = 1567 self.assertEqual(expected, actual)

Slide 11

Slide 11 text

単体テストがないと・・・ ● 変更があるたびに手動でテストしないといけない ● プロダクトコードのみだと何を渡した時に何が返ってくるか第三者に分かりづらい 単体テストがあると・・・ ● テストを実行するためのコマンドだけで正しく動いているか確認できる ● テストケースの内容をみるとどんな条件でどのように動くか確認できる ● レビュー時にテストケースをみることで確認すべき観点の過不足を確認できる 単体テストがあると便利

Slide 12

Slide 12 text

unittest.mockとは ● Pythonでテストする際に利用するPythonの標準ライブラリ。プロダクトコードの一部 をモックオブジェクトに置き換え、作成した関数やモジュールの振る舞いに関するア サーションメソッドを実行できる ● モックオブジェクトに置き換えることで構築に手間がかかるオブジェクトを高速に利 用できる ● 「mock」という言葉には「まねる」「まがいの」という意味があります。他のオブジェク トや関数のふりをした偽物として振る舞うイメージをもっていただければ ※本発表では、プロダクトコードの一部を置き換えたオブジェクトを「モックオブジェ クト」と呼び、置き換えることを「モックする」と呼びます。スタブとモックという概念が ありますが、時間の都合上言及しないので厳密には分けません。

Slide 13

Slide 13 text

例えばこんな関数をテストしたいとします kuji.py import random def kuji() -> str: fortune_number = random.randrange(10) if fortune_number % 2 == 0: return 'あたり' return 'はずれ'

Slide 14

Slide 14 text

import unittest 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, mock_random_number): actual = src.kuji.kuji() expected = 'はずれ' self.assertEqual(expected, actual) テストが通る時と通らない時がある事件発生 これじゃあ このテストが通れば安心と自 信をもちきれない!!

Slide 15

Slide 15 text

import unittest 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_lose(self, mock_random_number): actual = src.kuji.kuji() expected = 'はずれ' self.assertEqual(expected, actual) random.randrange(10)が実行さ れているため、 test_win_if_multiple_of_2で2で 割り切れない数字が利用されるこ ともある。 同じ考え方で、test_loseで2で割り 切れる数が利用され テストが通らないことも。 なぜ不安定なテストになってしまったのか

Slide 16

Slide 16 text

モックオブジェクトで安定したテストに class TestKuji(unittest.TestCase): @unittest.mock.patch('random.randrange') def test_win_if_multiple_of_2(self, mock_random_number): mock_random_number.return_value = 2 actual = src.kuji.kuji() expected = 'あたり' self.assertEqual(expected, actual) @unittest.mock.patch('random.randrange') def test_lose(self, mock_random_number): mock_random_number.return_value = 1 actual = src.kuji.kuji() expected = 'はずれ' self.assertEqual(expected, actual) テスト毎にランダムに値が 出力される random.randrange()を モックします return_valueで random.randrange()が 返す値を2に固定します このテストメソッドでは常に 2が返され、安定したテス トになります。

Slide 17

Slide 17 text

も〜っと!モックオブジェクト ● テストを実行する時間やタイムゾーンにテスト結果が左右されない ● データベースアクセスの箇所を置き換えることで、データベースの内容にテスト結果 が左右されない ● ・外部APIを利用する箇所を置き換えることで、ネットワークにテスト結果が左右され ない。また、テストのために何回もリクエスト送って申し訳ないのがなくなる -> テストが失敗する外的要因を排除できる -> 処理内容の変更やリファクタリングを安心して行える ● とはいえ、モックオブジェクトに置き換えた箇所の動きは保証されていないのでよく テストされたものを使うなど注意が必要

Slide 18

Slide 18 text

モックオブジェクトドッカ〜ン!とならないために 1. datetime.datetime.nowをモックできない問題 2. 外部APIを利用したテストをどうかけばいいのかわからない問題

Slide 19

Slide 19 text

datetime.datetime.now()をモックできない問題 import datetime def greet() -> str: now = datetime.datetime.now() if 5 <= now.hour < 12: return 'おはようございます' elif 12 <= now.hour < 18: return 'こんにちは' return 'こんばんは' https://t-wada.hatenablog.jp/entry/design-for-testability をPythonに書き換え greet.py

Slide 20

Slide 20 text

import datetime import unittest.mock import pytz import src.greet @unittest.mock.patch('datetime.datetime.now') def test_morning(self, mock_datetime): mock_datetime.return_value = datetime.datetime( 2019, 10, 1, 5, 0, 0, 0 ) expected = 'おはようございます' self.assertEqual(expected, src.greet.greet()) -> エラーになってテストできない test_greet.py ※CPythonで書かれたモジュールのオブジェクトが Immutableであることが原因です。 PyPy3.5で動作確認したところ、正常に動きました

Slide 21

Slide 21 text

案1: ライブラリ「freezegun」を使う from freezegun import freeze_time @freeze_time('2019-10-01 05:00:00') def test_morning(self): expected = 'おはようございます' self.assertEqual( expected, src.greet.greet() ) test_greet.py

Slide 22

Slide 22 text

案2: datetime.datetime.now()を引数にもつ class TestGreet(unittest.TestCase): def test_morning(self): expected = 'おはようございます' self.assertEqual( expected, src.greet.greet( datetime.datetime(2019, 10, 1, 5, 0, 0, 0) ) ) テストはこんな感じになって・・・ test_greet.py

Slide 23

Slide 23 text

def greet(now: datetime.datetime) -> str: if 5 <= now.hour < 12: return 'おはようございます' elif 12 <= now.hour < 18: return 'こんにちは' return 'こんばんは' 時間を引数にもたせると datetime.datetime.now()ではなく指定した日時で使いたい! という要望が出た時にも再利用できます。

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

テスト対象の関数 import http import requests def picnic(postal_code: str) -> str: 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 'ピクニックは決行' return 'ピクニックは延期' ピクニックの行き先の郵便番号を引数に入力します。 晴れならばピクニックは決行、それ以外ならば延期とします。

Slide 26

Slide 26 text

requests.getをモックしてネットワーク通信しない import unittest import unittest.mock import src.tenki class TestTenki(unittest.TestCase): @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 = { '天気': '晴れ' } actual = src.tenki.picnic(1050004) expected = 'ピクニックは決行' self.assertEqual(expected, actual) 天気しか利用しないので他の 項目は返していませんが、 入れても問題ありません GET以外の HTTPリクエストでも 同様にモックしてください

Slide 27

Slide 27 text

まとめ ● 単体テストを書くとリファクタリングや変更が安心 ● テストを高速に安定して実行するためにunittest.mockモジュールを使おう ● モックオブジェクトに置き換えた箇所の動きは保証されていないのでよくテストされ たものを使うなど注意が必要 ● 3rdパーティライブラリでモックできない問題を解決 ● オブジェクトを引数に渡すなどプロダクトコードの設計を見直す解決も ● モックオブジェクトで外部APIを利用した関数も怖くない

Slide 28

Slide 28 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 29

Slide 29 text

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