概要

Python で遊び始めた日から、8年が経過しているな。最近のブームは、作った Python 関数に対して、ユニットテストをきちんと作ることだ。それによってぼくの関数は信用に足るものとなる。ユニットテストについては、6年前にも挑戦したことがあったのだけど……

当時は nose を使っていたんだな。でもまあ、ぼくの趣味レベルでは、標準モジュールの unittest で十分かな。

今回は、モックを使ったユニットテストについて、実用的なサンプルをまとめるぜ。

 

テスト対象の関数 (my_module)

import logging

logger = logging.getLogger(__name__)


def _bar():
    raise NotImplementedError()


def foo(i: int) -> list[int | None]:
    """ユニットテストで遊ぶためのターゲット関数。

    与えた数のぶんだけ _bar を呼び出して、その結果を list[int | None] にして返す。
    _bar が Exception を投げたとき None が入ります。

    Args:
        i (int): _bar を呼び出す回数。
    """

    results: list[int | None] = []
    for _ in range(i):
        try:
            # _bar の結果を追加。
            results.append(_bar())
        except Exception:
            # なんかエラー出たら、ログ出して None を追加。
            logger.exception("例外発生!! ロギングしつつ処理は続行する。")
            results.append(None)
    return results

今回はモックで遊びたいので、モックが大活躍できるような foo を作ったぞ。 foo の中で呼ばれている関数 _bar は未実装なので、そのまま foo を呼び出すとゼッタイエラーになってしまう。 _bar をモックとすげ替えるテストを作ろうぜ。

 

ユニットテスト

 

レベル1

_bar15 を返すようにしようぜ。

from unittest import TestCase
from unittest.mock import patch, MagicMock


class TestFoo(TestCase):

    # _bar が 15 を返すようにしたい。 -> [15] が返ってくることを確認。
    @patch('my_module._bar', MagicMock(return_value=15, autospec=True))
    def test_foo(self):
        actual = foo(1)
        self.assertEqual(actual, [15])

レベル1だ。見やすいね。デコレータ部分を見るだけで、

  • _bar をパッチワークにしてるのね、
  • MagicMock とすげ替えているのね、
  • 15 が返るようにしているのね、
  • autospec で、ホンモノの _bar のシグネチャ (引数の数とか) を継承してくれてるのね、

……というのが分かる。

 

レベル2

まあこれ↑でもいいんだけれど、デコレータの中で MagicMock を定義する形式だと、この先、ちっと複雑なモック関数とすげ替えようと思ったとき限界がくる。デコレータの中で複雑なことをするのは不可能だから。

だからすげ替える関数を、 def 形式で作って、それを MagicMockside_effect として定義しよう。

from unittest import TestCase
from unittest.mock import patch, MagicMock


class TestFoo(TestCase):

    # 書き方を side_effect で統一したい。
    @patch('my_module._bar', autospec=True)
    def test_foo(self, mock_bar: MagicMock):

        def custom_bar(*args, **kwargs):
            return 15
        mock_bar.side_effect = custom_bar

        actual = foo(1)
        self.assertEqual(actual, [15])

これはぼくには結構見やすいな。

  • _bar をパッチワークにするのね、
  • すげ替える MagicMock をテスト関数の中で受け取れるのね、
  • MagicMockside_effect として、どんな関数へ置き換えるのか定義できるのね、

というのが見やすい。

ところで、すげ替える先の処理の名前が “side effect” なのって違和感があるよな。どちらかといえば “main effect” じゃないか? って感じがしちゃうもんね。これはだな、

  • この side effect は MagicMock の副作用である、
  • MagicMock の主目的は、 “本来であれば (_bar などが) 何か動きをするところを、何もしない、何も返さないようにすげ替える” ことである、
  • それをモックの主目的であると考えると、なにか具体的な値を返すことは、 MagicMock の副作用である、

……というように考えると、納得できる。

 

レベル3

レベル2で書いた、 “ちっと複雑なモック関数とすげ替えよう” としたパターンがこちらだ。

  • _bar が複数回呼ばれて、その度に違う値を返すようにしたいとか、
  • そのうちの何度かは Exception を投げるようにしたいとか、

そういう設定でモックの side effect を作りたい場合はこうなる。

from unittest import TestCase
from unittest.mock import call, patch, MagicMock


class TestFoo(TestCase):

    # _bar が 15, Exception, 8, Exception, 4 を順番に返すようにしたい。
    # side_effect が猛威を振るう。
    @patch('my_module._bar', autospec=True)
    @patch('my_module.logger.exception', autospec=True)
    def test_foo(self, mock_logger: MagicMock, mock_bar: MagicMock):

        def custom_bar(*args, **kwargs):
            nonlocal call_count
            call_count += 1
            if call_count == 1:
                return 15
            elif call_count == 3:
                return 8
            elif call_count == 5:
                return 4
            else:
                raise Exception()
        call_count = 0
        mock_bar.side_effect = custom_bar

        actual = foo(5)
        self.assertEqual(actual, [15, None, 8, None, 4])
        expected_calls = [
            call("例外発生!! ロギングしつつ処理は続行する。"),
            call("例外発生!! ロギングしつつ処理は続行する。")
        ]
        mock_logger.assert_has_calls(expected_calls)

ついでにロガーが2度呼ばれていることも確認しているぜ。そう、 MagicMock は、コールされたことを検知することもできる。