【Python】MagicMockを使ってみる

Table of Contents

概要

Pythonに限ったことではないですが、プログラムの単体テストを作成するときに、テスト対象のメソッドが依存するリソースをモックで置き換えることがあります。
たとえば、データベースから値を取得する処理をモックに置き換えたり、未完成のAPIに依存している部分をモックに置き換えたり、というものです。

モックを使うことによって、テスト対象そのもののテストに専念することができます。
たとえば、依存するメソッドにバグがあっても、テスト対象の動作が正しければテストを通すようにする、ということが可能になります。

Pythonの標準ライブラリであるunittestには、モックを実現するためのMagicMockというクラスが含まれています。
この記事では、MagicMockを使用したモックの実装例を紹介したいと思います。
unittestの詳細な使い方などは解説しませんので、公式ドキュメントなどを参照してください。

$ python -V
Python 3.10.12

例1

足し算を行うIntegerAddクラスと引き算を行うIntegerSubクラスがあります。
IntegerAddクラスで実際の計算を行うexec()メソッドはまだ実装されていません。
引き算を行うIntegerSubクラスはIntegerAddクラスに依存しており、a - ba + (-b)とすることで引き算を足し算として実現します。

以下のコードでは、IntegerSubクラスのテストを実装します。
このテストで確認したいのは、以下の2点です。

  1. IntegerAdd.exec()が想定通りの引数で実行されているか
  2. (IntegerAdd.exec()が正しい値を返すと仮定して)IntegerSub.exec()が正しい値を返すか
import unittest
from unittest.mock import MagicMock


class IntegerAdd:
    def exec(self, a: int, b: int) -> int:
        raise NotImplementedError


class IntegerSub:
    def __init__(self, add: IntegerAdd):
        self.__add = add

    def exec(self, a: int, b: int) -> int:
        return self.__add.exec(a, -b)


class TestIntegerSub(unittest.TestCase):
    def setUp(self):
        """
        各テストケースを実行する前に実行されるメソッドです
        """
        self.add = IntegerAdd()
        self.sub = IntegerSub(self.add)

    def test_exec(self):
        # IntegerAdd.exec()をモックに置き換える
        # 今回は10 - 2 = 8のテストを行う
        # IntegerAdd.exec()は10 + (-2) = 8を返すはずなので、モックのreturn_valueを8にする
        self.add.exec = MagicMock(return_value=8)

        # IntegerSub.exec()を実行する
        result = self.sub.exec(10, 2)

        # IntegerAdd.exec()がexec(10,-2)の引数で実行されたことを確認する
        self.add.exec.assert_called_with(10, -2)

        # 計算結果が8になっていることを確認する
        self.assertEqual(result, 8)

    def test_exec_2(self):
        def mock_ret(*args, **kwargs) -> int:
            return args[0] + args[1]

        self.add.exec = MagicMock(side_effect=mock_ret)
        result = self.sub.exec(10, 2)
        self.add.exec.assert_called_with(10, -2)
        self.assertEqual(result, 8)


if __name__ == "__main__":
    unittest.main()

test_exec()では、10 – 2 = 8のテストを実行します。
IntegerAdd.exec()は今回のテスト対象ではないのでモックに置き換えます。
モックの返り値はreturn_valueで指定します。

self.add.exec = MagicMock(return_value=8)

IntegerAdd.exec()が想定通りの引数を受け取って実行されたかどうかを確認するためには、assert_called_with()を使用します。
今回の場合は10 + (-2)なので、10と-2を引数として実行される必要があります。

self.add.exec.assert_called_with(10, -2)

IntegerSub.exec()が正しい値を返しているかどうかはassertEqual()で確認します。
今回の場合は10 – 2 = 8なので、期待値は8です。

self.assertEqual(result, 8)

先の例で示したように、MagicMockのパラメータreturn_valueを指定すると、モック実行時に返される値を指定できます。
モック実行時に渡された引数によってモックの返り値を変化させたい場合は、side_effectを使用します。

def mock_ret(*args, **kwargs) -> int:
    return args[0] + args[1]

self.add.exec = MagicMock(side_effect=mock_ret)

side_effectには関数を指定することができます。
今回の例だと、IntegerAdd.exec()は受け取った二つの整数を加算して返すのが想定される動作なので、そのような動作をする関数をside_effectに指定しています。

例2

天気予報を取得してそれに対応する絵文字を返すクラス(GetWeatherForecastEmojiUsecase)のテストを行うコードです。

from abc import ABC, abstractmethod
from unittest.mock import MagicMock
import unittest


class WeatherForecastRepository(ABC):
    @abstractmethod
    def get(self, city: str) -> str:
        raise NotImplementedError


class WeatherForecaseRepositoryStub(WeatherForecastRepository):
    def get(self, city: str) -> str:
        if city == "tokyo":
            return "sunny"
        elif city == "kyoto":
            return "cloudy"
        elif city == "nagasaki":
            return "rainy"
        else:
            raise RuntimeError(f"不明な都市名です: {city}")


class GetWeatherForecastEmojiUsecase:
    def __init__(self, repo: WeatherForecastRepository):
        self.__repo = repo

    def exec(self, city: str) -> str:
        weather = self.__repo.get(city)

        if weather == "sunny":
            return "☀"
        elif weather == "cloudy":
            return "☁"
        elif weather == "rainy":
            return "☂"
        else:
            raise RuntimeError(f"不明な天気です: {weather}")


class TestGetWeatherForecastEmojiUsecase(unittest.TestCase):
    def setUp(self):
        self.repo = WeatherForecaseRepositoryStub()
        self.usecase = GetWeatherForecastEmojiUsecase(self.repo)

    def test_exec(self):
        expected_values = {"sunny": "☀", "cloudy": "☁", "rainy": "☂"}
        for k, v in expected_values.items():
            self.repo.get = MagicMock(return_value=k)
            result = self.usecase.exec("tokyo")
            self.repo.get.assert_called_with("tokyo")
            self.assertEqual(result, v)

    def test_exec_unknown_weather(self):
        self.repo.get = MagicMock(return_value="sleet")
        with self.assertRaises(RuntimeError):
            self.usecase.exec("tokyo")

        self.repo.get.assert_called_with("tokyo")


if __name__ == "__main__":
    unittest.main()

WeatherForecastRepositoryは天気予報を取得する具体的な実装へのインターフェースを提供します。
実際に天気予報を取得するクラスはWeatherForecastRepositoryを実装して、外部APIを実行したりデータベースから値を取得したり、何らかの形で天気予報データを取得する必要があります。

WeatherForecaseRepositoryStubWeatherForecastRepositoryの仮の実装で、適当な値を返すだけです。
テストではこのクラスを使用しますが、実際のメソッドはモックで置き換えてしまうため、このクラスの中身については適当なもので問題ありません。

test_exec()の処理内容については先に示した例1と同じです。
モックで置き換えたメソッドが想定通りの引数で実行されていることと、テスト対象のメソッドが正しい値を返していることを確認しています。

test_exec_unknown_weather()では、テスト対象のクラスが想定外の値を受け取った場合の動作をテストしています。
モックから"sleet"という想定外の値が返されるため、GetWeatherForecastEmojiUsecase.exec()では例外が発生するのが正しい動作です。
例外が発生することを確認するためには、assertRaises()を使用します。

def test_exec_unknown_weather(self):
    self.repo.get = MagicMock(return_value="sleet")
    with self.assertRaises(RuntimeError):
        self.usecase.exec("tokyo")

    self.repo.get.assert_called_with("tokyo")