PyConJP2020の「unittest.mockを使ってテストを書こう」の発表資料です。
unittest.mockを使って テストを書こう ~モックオブジェクトを使ってより効率的で安定したテストに~2020/08/28 PyConJP2020@mizzsugar04251
View Slide
お前、誰よ● みずきと申します。● Twitter : @mizzsugar0425● PythonでWebサービスの開発をしています。● コーヒーと自転車が好きです。● 実はPyConの登壇は2回目。1回目は去年の飛び込みLT「PyConJP2018で勇気をもらってPythonエンジニアになった話」https://gihyo.jp/news/report/01/pyconjp2019/0002?page=4● 今年は飛び込みじゃない登壇が出来て嬉しいです。2
このトークの対象者● unittest.mockの存在は知っているけれどもイマイチ使いどころが分からない人3
前提● unittest特有の話ではないので、pytestなど他のテストライブラリでも使えます。● 単体テストのことを話します。● E2Eテストやシステムテストや結合テストについては話しません。● テスト駆動開発については話しません。● スライド内に収めるためにサンプルコードがPEP8に反していることがありますがご了承ください。4
このトークは3部に分かれます1部単体テストの目的2部単体テストでのunittest.mockの使い方3部unitest.mock Tips&アンチパターン5
1部 単体テストの目的6
今日これだけは持ち帰ってほしい(1部)● 単体テストの目的はクラスや関数の振る舞いが意図通りかの確認● テストコードで単体テストを自動化● 単体テストでは一つのテストメソッドで一つのことだけテスト7
単体テストって?● 単体テストの目的○ 作成したクラスや関数が意図した通りに振る舞うかの確認● 単体テストのテストコードを書いたら嬉しいこと○ テストを実行するコマンド一つですべてのテストケースを実行してくれます○ 振る舞いを変更した場合に正しく振る舞えるかコマンド一つで確認できます8
合計額を算出する関数をテストcalculate.pyimport dataclassesfrom typing import Iterable@dataclasses.dataclass(frozen=True)class Item:name: strprice: intdef price(items: Iterable[Item]) -> int:return sum(item.price for item in items)9
テストコード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 = 1600self.assertEqual(expected, actual)10
テストコードがあると楽に安心して変更できる@dataclasses.dataclass(frozen=True)class Item:name: strprice: intdef 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
割引クーポン導入に伴い、テストケース見直しclass TestCalculate(unittest.TestCase):def test_calculate(self):# 中略actual = src.calculate.price(items, discount_percentage=None)expected = 1600self.assertEqual(expected, actual)def test_calculate_with_discount(self):# 中略actual = src.calculate.price(items, discount_percentage=10)expected = 1440self.assertEqual(expected, actual)振る舞いの変更に応じてテストメソッド名も変更割引なしの場合と割引ありの場合のテストケースを作成関数の呼び出し方も変更12
割引クーポンの有効期間が適用されるように@dataclasses.dataclass(frozen=True)class Coupon:from_: datetime.dateto: datetime.datediscount_percentage: intclass Error(Exception):passclass InvalidCouponError(Error):pass13
14def 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
手動でのテストだと・・・(読まなくて良いです)>>> 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 / 1001599.9>>> # ↑()でくくるの忘れてた (本気でミスした)>>> int(1600 * (1 - 10 / 100))1440>>> ...15
クーポンの有効期間導入に伴いテストケース見直し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 = 1440self.assertEqual(expected, actual)16
# 有効期限切れのクーポン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 = 1600self.assertEqual(expected, actual)17
テストコードがないと・・・● 変更があるたびに手動で振る舞いを確認しないといけません● 何を検証したのか第三者に分かりづらいですテストコードがあると・・・● コマンド一つで振る舞いを確認できます● テストケースの内容をみるとどう検証したか確認できますテストコードで単体テストを自動化しよう18
モックの話に移ります19
2部 単体テストでのunittest.mockの使い方20
今日これだけは持ち帰ってほしい(2部)● 「モック」とはオブジェクトや関数のふりをした偽物として振る舞うこと● モックを使うことで関係ないところでテストが左右されないようになります。● MockとMagicMockならMagicMockを使いましょう。21
外的要因にテストが左右されないことが大事● 確認したいこと以外の箇所でテストの結果が左右されないべき● テスト対象の関数内で呼び出している関数も実行させるとテストが失敗した時に要因を探るのが大変なときも22
例えば・・・23
こんな関数をテストしたいとしますkuji.pyimport randomdef kuji() -> str:is_lucky = random.choice([True, False])if is_lucky:return 'あたり'return 'はずれ'24
テストが通る時と通らない時がある事件発生import app.kujiclass 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
なぜ不安定なテストになってしまったのかclass TestKuji(unittest.TestCase):def test_win_if_True(self):actual = src.kuji.kuji()expected = 'あたり'self.assertEqual(expected, actual)random.choiceの実行結果がFalseになる場合もある26
1つのことだけ確認したい● 標準モジュールで保証されているrandom.choice自体の動きまで確認したくありません● random.choiceの出力によって「あたり」が出るか「はずれ」が出るかが大事27
関係ないものはモックしよう28
関係ないものはモックしよう● 「モック」という言葉には「偽の」「まがいの」という意味があります。● プログラム内のオブジェクトや関数のふりをした偽物として振る舞うイメージ● Pythonでは、標準ライブラリ「unittest.mock」を使って、プログラム内の関数やオブジェクト(ここではまとめてコンポーネントと呼びます)を仮のコンポーネントに置き換えます。● テスト技術者は仮のコンポーネントの挙動を指定することが出来ます。● 仮のコンポーネントに置き換えることで構築に手間がかかるオブジェクトを高速に利用できます。29
この発表における「モック」という言葉の扱い①● xUnitの文脈では、プログラムの中でオブジェクトや関数やモジュール(以下これらをまとめて「コンポーネント」と呼びます)の代品として動く仮のコンポーネントを「テストダブル」と呼びます。● 「テストダブル」のうち、テスト対象物が利用するコンポーネントがテスト技術者が指定した通りに挙動するものを「テストスタブ」と呼びます。● 「テストダブル」のうち、テスト対象物が利用するコンポーネントにどのようなアクセスがあったか記録するものを「テストスパイ」と呼びます。● xUnitにおける「モックオブジェクト」は、コンポーネントへのアクセスを検証するのに利用しますが、「テストスパイ」と異なり処理の途中で検証します。30
この発表における「モック」という言葉の扱い②31
この発表における「モック」という言葉の扱い③● unittest.mockでは、テストスタブとテストスパイ両方出来ます。● unitest.mock.Mockクラスのインスタンスが両方を担います。● この発表では、xUnitの文脈で「テストダブル」と呼んでいるものをまとめて「モックオブジェクト」と言います。● この発表では、オブジェクトや関数やモジュールをxUnitでいうテストダブルに置き換えることを「モックする」と言います。32
モックオブジェクトでテストを改善import unittest.mockclass TestKuji(unittest.TestCase):def test_win_if_is_lucky(self):import randomrandom.choice = unittest.mock.Mock(return_value=True)actual = kuji.kuji()expected = 'あたり'self.assertEqual(expected, actual) return_valueでrandom.choice()が返す値をTrueに固定しますこのテストメソッドでは常に Trueが返され、安定したテストになります。テスト毎にランダムに値が出力されるrandom.choiceをモックします33
インスタンスを置き換えることも可能>>> 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')34p自体をモックオブジェクトにしたい
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
関数が呼ばれたことを記憶>>> 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, inassert_not_calledraise AssertionError(msg)AssertionError: Expected 'greet' to not have been called. Called 1 times.>>> m.greet.assert_called_with('hello') # 引数に何を与えられたのかを記憶36
引数を確認するならcall_args37>>> m.greet('hello')>>> m.greet.call_args # 引数を確認call('hello')>>> m.greet.call_args[0] # 位置引数をタプルで確認('hello',)>>> m.greet.call_args[0][0] # 1番目の位置引数を確認hello
キーワード引数を確認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
位置引数とキーワード引数両方使っている場合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'}
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 == expectedTrue
call_countで呼び出された回数を確認41>>> m.greet('good morning')>>> m.greet('hello')>>> m.greet('good night')>>> m.greet.call_count3
unittest.mock.patchデコレータを使ったモックの表現@unittest.mock.patch('random.choice')def test_win_if_is_lucky(self, mock_choice):mock_choice.return_value = Trueactual = kuji.kuji()expected = 'あたり'self.assertEqual(expected, actual)このテストメソッド内では、random.choiceはmock_choiceという変数で利用されます。'package.module.ClassName'の形式にします。42
モックオブジェクトを使って嬉しいこと● テストが失敗する外的要因を排除○ テストを実行する時間○ タイムゾーン○ データベースの内容○ ネットワークの状況43
休憩44
休憩中にやること● 水分とりましょう● 深呼吸して落ち着きましょう● 折返し地点まで来ました。残り時間を確認しましょう● 気合入れて再スタートしましょう!45
unittest.mock.Mockクラスの仕組み46
MockクラスのインスタンスはCallable①● Callableとは、関数呼び出し出来るオブジェクトのこと>>> def f() -> str:... return 'This is Callable.'...>>>>>> f # これはCallable>>> f() # これはstr'this is Callable'47
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() # これはint3
MockとMagicMockの違い①● MagicMockはMockの上位互換● 基本的な使い方は同じ● MockはPythonのマジックメソッドが実装されていない● MagicMockはほぼ全てのマジックメソッドが実装されている● MagicMockを使った方が便利49
MockとMagicMockの違い②MagicMockでは通常のメソッドと同じようにマジックメソッドをモックできます。50>>> m = unittest.mock.MagicMock()>>> m.__str__.return_value = 'aa'>>> str(m)aa
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'
MockとMagicMockの違い④magic method を mock するには、対象の method に対して関数や mock のインスタンスをセットします。もし関数を使う場合、それは第一引数に self を取る 必要があります。 https://docs.python.org/ja/3/library/unittest.mock.html#mocking-magic-methods52>>> def __str__(self):... return 'a'...>>> m = unittest.mock.Mock()>>> m.__str__ = __str__>>> str(m)'a'
MockとMagicMockの違い⑤magic method は通常のメソッドとはルックアップ方法が異なるので 2, magic methodのサポートは特別に実装されています。そのため、サポートされているのは特定のmagic method のみです。Magic method はインスタンスではなくクラスからルックアップされるはずです。Pythonのバージョンによってこのルールが適用されるかどうかに違いがあります。サポートされているプロトコルメソッドは、サポートされているすべての Python のバージョンで動作するはずです。※Python公式ドキュメントから引用。色と強調はドキュメントとは別に自分でつけました。53https://docs.python.org/ja/3/library/unittest.mock.html#magicmock-and-magic-method-support
3部 unittest.mock Tips&アンチパターン54
unittest.mockアンチパターン● モックオブジェクトに置き換えている所をテストしていない● ソースコードを変更する際にモックオブジェクトの使い方を見直さない55
置き換えている箇所をテストしていない● よくテストしている箇所、もしくはテストが不要なほど細分化された箇所のみをモックオブジェクトに置き換えるべきです。● 標準ライブラリをテストするようなものは逆にテスト不要です。56def dump_dict(source):json.dumps(source).encode()テスト不要な関数の例
モックオブジェクトの使い方を見直さない● ソースコードを変更する際に、それに関わるテストで使っているモックオブジェクトの使い方を見直さないのはNGです。● レビューで注意してチェックしましょう。● mypyやflake8を使った静的解析で変更の漏れを防ぎましょう。57
単体テストは通るが静的解析は通らない例①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
単体テストは通るが静的解析は通らない例②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
単体テストは通るが静的解析は通らない例③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
単体テストは通るが静的解析は通らない例④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このテストは通りましたが実行時にエラーになりました。
型ヒントで静的解析してバグを防ごう①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):...62app.pyこうやって型ヒントを書いて…
型ヒントで静的解析してバグを防ごう②>>> pip install mypy>>> mypy app.pyapp.py:23: error: Argument 1 to "list_applicants" has incompatible type"date"; expected "Tuple[datetme.date, datetime.date]"63download_applicants_of_date_csv内で使っているlist_applicantsが意図しない型の引数を受け取っているので注意してくれます。
mypyから指摘を受けてプロダクトコード修正64def 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
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
unittest.mock Tips1. unittest.mock.patchが当たらない2. 関数を実際に実行してほしくない3. datetime.datetime.nowをモックできない4. 外部APIを利用したテストをどうかけばいいのかわからない66
1. unittest.mock.patchがあたらないsrc/sample.pydef get_num():from random import randintreturn randint(1, 10)67
from … import … した関数が当たらないtest_sample.pyimport src.sampleclass 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 = 1self.assertEqual(actual, expected)randintをモックしたので1を返すはずなのに1にならない68
秘密はfrom … import ...の仕組みにあった69
from … import ...を書いたモジュールに変更import src.sampleclass 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 = 1self.assertEqual(actual, expected)70
2. 関数を実際に実行してほしくないsns.pyimport some_sns_moduledef send_share_sns(text: str) -> None:"""textに指定した文字列をとあるSNSでシェアします"""some_sns_module.share(text) # 実際にSNSに通信してほしくない71
assert_called_once_withを使うassert_calledから始まる名前のアサーションメソッドを使うと、モックした関数が呼ばれたことを確認できます。import snsclass 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
3. datetime.datetime.now()をモックできないhttps://t-wada.hatenablog.jp/entry/design-for-testability をPythonに書き換えgreet.pyimport datetimedef greet() -> str:now = datetime.datetime.now()if 5 <= now.hour < 12:return 'おはようございます'elif 12 <= now.hour < 18:return 'こんにちは'return 'こんばんは'73
-> エラーになってテストできないtest_greet.pyclass 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
案1: ライブラリ「freezegun」を使うtest_greet.pyfrom freezegun import freeze_timeclass TestGreet(unittest.TestCase):@freeze_time('2020-8-28 11:50:00')def test_morning(self):expected = 'おはようございます'self.assertEqual(expected, src.greet.greet())75https://github.com/spulec/freezegun
案2: datetime.datetime.nowを引数にもつテストはこんな感じになって・・・test_greet.pyclass 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
時間を引数にもたせると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
外部APIを利用した関数のテストが書けない日本の天気を取得するAPIを利用してピクニックの実施を判定する関数をテストするとします。https://tenki.example.com/today/[郵便番号(7桁の半角数字ハイフンなし)]にGETでリクエストを送ると下記のようなJSONが返されます。存在しない郵便番号を指定してリクエストが送られたらHTTPステータスコードが404でレスポンスが返されます。※これは架空のAPIであり、実際には使用できません{'郵便番号': '1050004''都道府県': '東京都','市区町村': '港区新橋','天気': '晴れ'}78
テスト対象の関数import httpimport requestsdef 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 ValueErrorif response.json()['天気'] == '晴れ':return Truereturn Falsepicnic.py79
案① 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
案②モックライブラリresponsesを使うimport responsesclass TestPicnic:@responses.activedef 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
まとめ● 単体テストの目的はクラスや関数の振る舞いが意図通りかの確認● 単体テストでは一つのテストで一つのことだけテスト● 「モック」とはオブジェクトや関数のふりをした偽物として振る舞うこと● モックすることで関係ないところでテストが左右されないように● unittest.mockではモックした関数の振る舞いを指定できます● unittest.mockではモックした関数がどのように呼ばれたのか確認できます82
参考文献● テスト駆動開発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/ref=sr_1_1?qid=1570540448&refinements=p_27%3ASteve+Freeman&s=books&sr=1-1&text=Steve+Freeman● Pylons 単体テストガイドラインhttp://docs.pylonsproject.jp/en/latest/community/testing.html● 現在時刻が関わるユニットテストから、テスト容易性設計を学ぶhttps://t-wada.hatenablog.jp/entry/design-for-testability83