内藤 裕二/ 2022年 6月 22日/ 技術

こんにちは!内藤です!
pythonを触り始めて3年くらい経つのですが、ようやくユニットテストのプラクティスがたまってきました。
その中でも劇的な威力を発揮したのが標準モジュールのmockなので、備考録を兼ねて、ブログにしておきます。

TL;DR

  • メンバとして持っているオブジェクトのふるまいを切り替える
    • メソッド自体をmockに置き換え
  • 乱数を使用したメソッドのユニットテスト
    • デコレータで標準関数をmockに置き換え
  • 親クラスの特定のメソッドが呼び出されていることを確認する
    • デコレータで特定クラスのメソッド呼び出しをmockに置き換え

unittest.mock の概要

unittest.mockは python 3.3 より標準ライブラリとなった、テスト用のモジュールです。
どんなことができるのかについては優良な記事がたくさんあるので、そちらをご覧ください。
いくつか挙げておきます。

ここでは、いままで自分で書いてきたユニットテストの中から、mockの威力を実感したケースについて記載していきます。

メンバとして持っているオブジェクトのふるまいを切り替える

下記のようなコードにおける、HolidayCheckerクラスのis_five_day_work_satisfiedについてのユニットテストです。

ファイル:days.py

from __future__ import annotations

import datetime as dt

class DayInfo:
    def __init__(self, *args, **kwargs):
        self._is_holiday = True
        self._datetime = dt.datetime.now()

    def is_holiday(self) -> bool:
        """休日かどうかを返す"""
        # 以下、実際にはものすごく複雑で面倒な判定条件
        if self._is_holiday:
            # 特定の休日は休み
            return True
        if self._datetime.weekday() == 5 or self._datetime.weekday() == 6:
            # 土日は休み
            return True
        # そうでなければ、出勤
        return False

class HolidayChecker:
    def is_five_day_work_satisfied(self, days: list[DayInfo]) -> bool:
        """週休2日かどうか確認する"""
        return len([x for x in days if x.is_holiday()]) >= 2

ファイル:test_days.py

from __future__ import annotations

import datetime as dt
from unittest import mock

from days import DayInfo, HolidayChecker

class TestHolidayChecker:
    def setup_method(self, method: str) -> None:
        pass

    def teardown_method(self, method: str) -> None:
        pass

    def test_is_five_day_work_satisfied(self):
        days = [DayInfo() for _ in range(7)]
        days[0].is_holiday = mock.Mock(return_value=False)
        days[1].is_holiday = mock.Mock(return_value=False)
        days[2].is_holiday = mock.Mock(return_value=False)
        days[3].is_holiday = mock.Mock(return_value=False)
        days[4].is_holiday = mock.Mock(return_value=False)
        days[5].is_holiday = mock.Mock(return_value=True)
        days[6].is_holiday = mock.Mock(return_value=True)
        obj = HolidayChecker()
        assert obj.is_five_day_work_satisfied(days)

コード中のコメントにも書きましたが、実際にはDayInfoクラスのis_holidayは複雑怪奇な条件だと思ってください。

is_five_day_work_satisfiedメソッド自体は単純なのですが、そのメソッドが呼び出している別のメソッドが複雑怪奇な場合です。
unittest.mockは特定のオブジェクトのメソッドを、固定値を返すモックオブジェクトにすげかえることができるので、DayInfoをどう生成するかを考えなくて良くなります。

乱数を使用したメソッドのユニットテスト

下記のようなコードにおける、SchrodingersCatクラスのis_aliveについてのユニットテストです。

ファイル: schrodingers_cat.py

from __future__ import annotations

import random

class SchrodingersCat:
    def is_alive(self) -> bool:
        """箱をあけて猫の状態を確認する"""
        if random.randint(0, 99) < 50:
            # 生きてる
            return True
        else:
            # 死んでる
            return False

ファイル: test_schrodingers_cat.py

from __future__ import annotations

from unittest import mock

from schrodingers_cat import SchrodingersCat

class TestSchrodingersCat:
    def setup_method(self, method: str) -> None:
        pass

    def teardown_method(self, method: str) -> None:
        pass

    @mock.patch("random.randint")
    def test_is_alive(self, randint_mock: mock.Mock):
        randint_mock.return_value = 0
        obj = SchrodingersCat()
        assert obj.is_alive()
        randint_mock.return_value = 49
        assert obj.is_alive()
        randint_mock.return_value = 50
        assert not obj.is_alive()
        randint_mock.return_value = 99
        assert not obj.is_alive()

乱数を使用するメソッドのユニットテストは非常に面倒なのですが、mockモジュールを使用すると標準関数をmockオブジェクトにすげかえることができます。
mock.patchデコレータを使用すると、指定した関数をすげかえたmockオブジェクトが関数の引数に渡されてきます。
mockモジュールが協力なのは、標準モジュールの関数でも容赦なくすげ替えができる点です。
ここでは乱数を生成する標準モジュールの関数random.randintをmockにすげかえ、戻り値を固定してテストしています。

親クラスの特定のメソッドが呼び出されていることを確認する

下記のようなコードにおける、Childクラスのset_optionsについてのユニットテストです。

ファイル: inheritance.py

from __future__ import annotations

class Parent:
    def set_options(self) -> None:
        """なにがしかのオプション設定する"""
        self.opt1 = 0
        self.opt2 = 1

class Child(Parent):
    def set_options(self) -> None:
        """親クラスのメソッドを呼び出す"""
        super().set_options()
        self.opt3 = 2

ファイル: test_inheritance.py

from __future__ import annotations

from unittest import mock

from inheritance import Child

class TestChild:
    def setup_method(self, method: str) -> None:
        pass

    def teardown_method(self, method: str) -> None:
        pass

    @mock.patch("inheritance.Parent.set_options")
    def test_set_options(self, set_options_mock: mock.Mock):
        obj = Child()
        obj.set_options()
        assert set_options_mock.called  # 親のメソッド呼び出してる?

継承先のクラスのメソッドが継承元のメソッドを呼んでいることを確認するテストです。
乱数のときと同様、mock.patchデコレータを使用して、親クラスのメソッドをmockオブジェクトにすげかえます。
継承先クラスのメソッドを呼び出して、mockオブジェクトに呼び出しが記録されているかを確認することで、簡単に記述することができます。

おわりに

unittest.mockに関して、最近感動したケーススタディをまとめてみました。
何かのお役に立てれば幸いです。