$30 off During Our Annual Pro Sale. View Details »

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

mizzsugar
August 28, 2020

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

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

mizzsugar

August 28, 2020
Tweet

More Decks by mizzsugar

Other Decks in Programming

Transcript

  1. unittest.mockを使って

    テストを書こう

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  6. 1部 単体テストの目的
    6

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  14. 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  19. モックの話に移ります
    19

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  23. 例えば・・・
    23

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  35. 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

    View Slide

  36. 関数が呼ばれたことを記憶
    >>> 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  40. 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

    View Slide

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

    View Slide

  42. 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

    View Slide

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

    View Slide

  44. 休憩
    44

    View Slide

  45. 休憩中にやること
    ● 水分とりましょう
    ● 深呼吸して落ち着きましょう
    ● 折返し地点まで来ました。残り時間を確認しましょ

    ● 気合入れて再スタートしましょう!
    45

    View Slide

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

    View Slide

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

    >>> f() # これはstr
    'this is Callable'
    47

    View Slide

  48. 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

    View Slide

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

    View Slide

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

    View Slide

  51. 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'

    View Slide

  52. 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'

    View Slide

  53. 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  64. 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

    View Slide

  65. 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

    View Slide

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

    View Slide

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

    View Slide

  68. 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

    View Slide

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

    View Slide

  70. 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

    View Slide

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

    View Slide

  72. 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

    View Slide

  73. 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

    View Slide

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

    View Slide

  75. 案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

    View Slide

  76. 案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

    View Slide

  77. 時間を引数にもたせると
    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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide