28
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Python の unittest の mock

Last updated at Posted at 2023-07-03

概要

Python の unittest を使っていて、mock が何をするものかは分かっているけど、まだちょっと得体が知れない、怖い、という段階があると思います。
この段階を克服するために、何も知らない状態から徐々に mock を理解するためのステップを作りたいと思いました。

対象は Python 3.x です。

どんな時にモックしたいか

単体テストでは、個々の機能を切り離してテストしたいものです。

たとえば A という関数をテストしたい時、A の中で B という関数を呼んでいるとします。
テストしたいのは A の機能なので、B にバグがあっても A にバグがなければテストはパスさせたいです。
また、このテストの期待結果を決めるのに、B からの返り値が予想または操作できると嬉しいです。
mock_1.png
そこで、テスト中だけは B を呼ぶ代わりに、期待通りに動く操り人形のようなものを呼ぶようにします。
この操り人形を B のモックといいます。
また、このように呼び出し先をすげ替えることを「モックする」と言ったりします。
mock_2.png

具体例

具体例として、チケット購入サイトにあるような「空席状況」を表示するプログラムを考えます。
といっても難しいことは無くて、残席数によって「完売」「残り僅か」「余裕あり」のいずれかを返すだけです。

イメージしやすいように Event というクラスを作って、空席状況を返す関数 (関数 A) を定義しましょう。

my_class.py
from my_func import get_available_seats

class Event:
    '''イベント
    '''
    def __init__(self, id: int):
        self.id = id

    def check_availability(self):
        '''空席状況を返す関数 (関数 A)
        '''
        # 残席数と座席数を取得する
        available_seats = get_available_seats(self.id)

        if available_seats == 0:
            return '完売'
        elif available_seats < 20:
            return '残り僅か'
        else:
            return '余裕あり'

残席数 (available_seats) は、別モジュールの関数 (関数 B) を呼んで取得しています。

my_func.py
import random

def get_available_seats(event_id: int) -> int:
    '''残席数を返す関数 (関数 B)
    '''
    # 残席数 (本来はデータベースに問い合わせるが、ここでは乱数)
    available_seats = random.randint(0, 100)

    return available_seats

このような情報はふつうはデータベースに問い合わせて取得しますが、話を簡単にするために乱数にしています。
なので event_id (イベント ID) 引数を使っていませんが、関数の役割をイメージしやすいように引数を残してあります。

これらのモジュールは同じフォルダにあるものとし、パッケージ化していません。

./
 ├─ my_class.py
 └─ my_func.py

動作確認

動作を見るには同じフォルダから以下のようなプログラムを実行します。

main.py
from my_class import Event

# Event を作る
event = Event(id=1)

# 空席状況を取得する
availability = event.check_availability()

print(availability)

出力はこのようになります。

> python ./main.py
余裕あり

ふつうにテストを書いてみる

関数を呼んで期待した結果が返ってくることを確認する、という「ふつうのテスト」を unittest で書いてみます。

tests/test_00.py
from unittest import TestCase
from my_class import Event

class EventTest(TestCase):
    def test_availability(self):
        # Event を作る
        event = Event(id=1)

        # 空席状況を取得する
        availability = event.check_availability()

        # いずれかの空席状況が返ってきたことを確認する
        self.assertIn(availability, ['完売', '残り僅か', '余裕あり'])

テストの実行は以下のようにします (tests フォルダの中にテストコードを書いた場合)。

> python -m unittest tests.test_00
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

テストコードから my_class モジュールをインポートしているので、上記のコマンドは my_class モジュールのあるフォルダで実行する必要があります。
また、上記のように tests.test_00 で実行する場合は tests フォルダがパッケージになっている (__init__.py を含む) 必要があります。

このテストの問題点

このテストの問題点は、関数 A の「空席状況を残席数に基づいて判定する」という機能をテストできていないことです。
その機能を確認するには、テスト中に残席数 (関数 B の返り値) を知る必要がありますが、それはテストコードからは見えません。

また、仮にその情報を得られたとしても、その時どうだったかだけでなく「残席数が〇の場合は?」という気になるパターンについても確認したいです。

これがモックを使いたい状況です。

自力でモックしてみる

モックを理解する最初のステップとして、自力でモックを作ってみましょう。

最初に書いたように、モックは関数 B をすげ替えるものです。
関数 A が get_available_seats() を呼んだ時に、モックが呼ばれるようにすれば良いのです。

tests/test_01.py
from unittest import TestCase
import my_class
from my_class import Event

class EventTest(TestCase):
    def test_availability(self):
        # Event を作る
        event = Event(id=1)

        # モック用の関数 (必ず 0 を返す)
        def mock_func(id):
            return 0

        # 関数 B をモック用の関数にすげ替える
        my_class.get_available_seats = mock_func

        # 空席状況を取得する
        availability = event.check_availability()

        # 空席状況が「完売」であることを確認する
        self.assertEqual(availability, '完売')

何をやっているかというと、必ず 0 を返す関数を作って、それを my_class.get_available_seats に代入して (すげ替えて) います。

「関数に関数を代入する」ということの意味が分からない方は、「関数も Python のオブジェクトである」ということと、「オブジェクトの名前はその実体を指すポインタである」ということをイメージしてみてください。

これで、「残席数が 0 だった場合」のテストはできるようになりました。

すげ替える時の注意点

上記の自力のモックでは、my_class モジュールをインポートして、その get_available_seats 属性を変更して (すげ替えて) います。

これ以外の、たとえば以下のような方法では上手くいきません。

関数のすげ替えが上手くいかない例
from my_class import get_available_seats

class EventTest(TestCase):
    def test_availability(self):
        # (中略)
        get_available_seats = mock_func

先ほどの例とは import の書き方が違います。
上手くいかない理由は import で何が起きているかを知れば分かります。

import で起きていること

import で起きていることについて、少し説明します。
ひと言でいうと、別の名前空間にある変数を、今いる名前空間にコピーしています。
ただしコピーされるのはポインタ、つまり「何を指しているか」という情報です。

たとえば、先ほどの上手くいかないパターンでは以下のことが起きています。
ステップ・バイ・ステップで見てみましょう。

1. 元の関数 B の定義

まずは import の前に def で関数を定義した時、関数の実体とそれを呼ぶための名前が作られます。
import1.png
2. my_class に import

関数 (の名前) を my_class モジュールに import すると、その名前空間にコピーが作られます。
コピー元と同じ関数を指しますが、あくまでコピーです。
関数 A は、このコピーの方を使って関数 B を呼びます。
import2.png
3. テストコードに import

これをさらにテストコードに import すると以下のようになります。
関数 A が呼んでいるものをすげ替えたいので、これで良いようにも思えますが…。
import3.png
4. モックすると…

なんと、これをモック用の関数にすげ替えても、関数 A から呼ばれる関数 B は変わりません。
テストコードに import したものは、その時点でコピーになっていたからです。
import4.png
テストをしている名前空間は tests.test01 ですが、関数 A は相変わらず my_class にいるので、そっちの方を書き換えないといけなかったのです。

正しい import とモックのしかた

正しくモックするには、「自力でモックしてみる」の最初のコードのように my_class モジュールを import します。
すると、my_class という「モジュールを指すポインタ」がコピーされます。
import5.png
これを使って my_class.get_available_seats と書けば、my_class モジュールの中の get_available_seats (コピーでなくそのもの) にアクセスできます。
import6.png
モック用の関数をそこに代入すると以下のようになり、すげ替えが上手くいきます。
import7.png

何を import してモックするべきかという話題については、後ほど説明する "patcher" に関する公式ドキュメントに説明があります。

unittest の mock

公式ドキュメントの unittest.mock のページ を見ると、いきなり以下のコードが載っていて取っつきづらいと思います。
頑張ってこれを理解するのではなく、先ほどの例を使って mock の使い方を見ていきましょう。

公式ドキュメントにあるサンプル
>>> from unittest.mock import MagicMock
>>> thing = ProductionClass()
>>> thing.method = MagicMock(return_value=3)
>>> thing.method(3, 4, 5, key='value')
3
>>> thing.method.assert_called_with(3, 4, 5, key='value')

少し説明すると、3行目で MagicMock クラス のオブジェクトを作って、thing.method という関数をそれにすげ替えています。
ここからは先ほど自力でやったのと同じことを、この MagickMock クラスでやってみます。

「関数は関数で置き換えるべきなのでは」と思うかも知れませんが、Python ではオブジェクトも関数のように「呼び出せる」ようにできます。
見方を変えて、関数が「呼び出し可能オブジェクト」の一種だと考えても良いです。

MagicMock を使ってみる

先ほどの空席状況の例で MagicMock を使った場合の書き方は、このようになります。

tests/test_02.py
from unittest import TestCase
from unittest.mock import MagicMock
import my_class
from my_class import Event

class EventTest(TestCase):
    def test_availability(self):
        # Event を作る
        event = Event(id=1)

        # 関数 B を MagicMock オブジェクトにすげ替える
        my_class.get_available_seats = MagicMock(return_value=0)

        # 空席状況を取得する
        availability = event.check_availability()

        # 空席状況が「完売」であることを確認する
        self.assertEqual(availability, '完売')

MagicMock オブジェクトに return_value という属性を設定すると、それが関数として呼び出された時に何を返すかを決められます。

return_value は属性なので、オブジェクトを作った後で設定したり変更したりできます。

tests/test_03.py
class EventTest(TestCase):
    def test_availability(self):
        # Event を作る
        event = Event(id=1)

        # 関数 B を MagicMock オブジェクトにすげ替える
        my_class.get_available_seats = MagicMock()

        # 残席数が 0 なら空席状況が「完売」となること
        my_class.get_available_seats.return_value = 0
        availability = event.check_availability()
        self.assertEqual(availability, '完売')

        # 残席数が 19 なら空席状況が「残り僅か」となること
        my_class.get_available_seats.return_value = 19
        availability = event.check_availability()
        self.assertEqual(availability, '残り僅か')

どうなっているのか

使い方は分かっても、この「属性を設定するだけで返り値が変わる」という動作が気持ち悪いと、この先の理解がスムーズにいかないかも知れません。
蛇足かもしれませんが、これがどういう仕組みなのかを想像するために、自力でモックオブジェクトを作ってみます。

my_mock.py
class MyMock:
    def __init__(self, return_value=None):
        self.return_value = return_value

    def __call__(self, *args, **kwargs):
        return self.return_value

この __call__ というのが、上で補足説明に書いた「呼び出し可能オブジェクト」にするための特殊な関数名になっています (これは Python のルールです)。
先ほどのコードくらいであれば、MagicMock をこの MyMock で置き換えても動きます。

実際の MagicMock はこんなに単純ではないので、MyMock はあくまでモックオブジェクトがどうなっているのかの理解のためと思ってください。

Mock クラスと MagicMock クラス
Python の unittest の mock の説明で、MagicMock 以外に Mock クラス というのが出てくることがありますが、これはあまり気にしなくても大丈夫だと思います。
MagicMockMock の拡張版なのですが、あえて Mock を使う理由はあまり無いですし、この後の "patcher" を使う方法では自動的に MagicMock が使われます。
ただしドキュメントを読む時に MagicMock の継承元である Mock の方を読む必要はあります。

モックを元に戻すには

関数にモックオブジェクトを代入してすげ替える方法を見てきました。
テスト中にこれを元に戻したい場合は、すげ替える前に元の関数を退避しておけば可能です。
このことは「代入によってポインタがコピーされる」という理屈から分かると思います。

モックを元に戻す例
# 元の関数を退避する
original_func = my_class.get_available_seats

# モックする
my_class.get_available_seats = MagicMock()

# (この関数が呼ばれるようなテスト)

# モックを元に戻す
my_class.get_available_seats = original_func

patcher を使うともっと簡単にできます。

patcher で一時的にモックする

patcher は、あるスコープ内だけで一時的にモックを適用するためのもの (という概念) です。
patcher の実装は unittest では patch 関数 で、コンテキストマネジャやデコレータとして使えます。

コンテキストマネジャとして使う

まずはコンテキストマネジャとして with で使う方法を見てみます。

tests/test_05.py
from unittest import TestCase
from unittest.mock import patch
from my_class import Event

class EventTest(TestCase):
    def test_availability(self):
        # Event を作る
        event = Event(id=1)

        with patch('my_class.get_available_seats') as mock_func:
            # 残席数が 0 の場合は「完売」となること
            mock_func.return_value = 0
            availability = event.check_availability()
            self.assertEqual(availability, '完売')

            # 残席数が 19 の場合は「残り僅か」となること
            mock_func.return_value = 19
            availability = event.check_availability()
            self.assertEqual(availability, '残り僅か')

patch 関数の第1引数は、モックしたいものの名前を (名前空間を含め) 文字列にしたものです。
この書き方であれば、前述の「すげ替える時の注意点」のようなことはあまり考えなくて済みます。

with ブロックを出ると関数はモックする前の状態に戻りますが、モックオブジェクト (mock_func) は残るので、モック中に得た情報を確認することができます。

モック中に得た情報を確認する例
class EventTest(TestCase):
    def test_availability(self):
        # (中略)
        with patch('my_class.get_available_seats') as mock_func:
            # (中略)

        # my_class.get_available_seats が2回呼ばれたことを確認する
        self.assertEqual(mock_func.call_count, 2)

ここで使った call_count のような、モックに何が起きたかを知るための属性については、公式ドキュメントの Mock クラスの説明 に載っています。

デコレータとして使う

patcher をデコレータとして使うと、with ブロックではなく関数やクラスのスコープで一時的にモックすることができます。
次のコードは先ほどと同じことをデコレータで書いた例です。

tests/test_06.py
from unittest import TestCase
from unittest.mock import patch
from my_class import Event

class EventTest(TestCase):
    @patch('my_class.get_available_seats')
    def test_availability(self, mock_func):
        # Event を作る
        event = Event(id=1)

        # 残席数が 0 の場合は「完売」となること
        mock_func.return_value = 0
        availability = event.check_availability()
        self.assertEqual(availability, '完売')

        # 残席数が 19 の場合は「残り僅か」となること
        mock_func.return_value = 19
        availability = event.check_availability()
        self.assertEqual(availability, '残り僅か')

        # my_class.get_available_seats が2回呼ばれたことを確認する
        self.assertEqual(mock_func.call_count, 2)

デコレータで書いた場合は、作られたモックオブジェクトは関数の引数として受け取ることができます。

複数のデコレータを使う場合の引数の受け取り方については、こちらをご覧ください。

patcher を使うべき理由

モックを戻す時は、自力で元の関数に戻すのでなく patcher を使うべきです。
理由は、テストが失敗した時に patcher でモックしたものは自動的に元に戻ってくれるからです。

テストが失敗した時 (AssertionError が出た時) は、そこで関数の実行が終わってテストランナーに処理が戻ります。
この時、コンテキストマネジャなら with ブロックを出る処理が、デコレータなら関数を出る処理がちゃんと行われるので、モックが元に戻ってから次のテストが実行されるのです。

その他の基本パターン

ここまで、モックオブジェクトの基本原理についてなるべく分かりやすく説明したつもりです。
この原理を知らなくても、何となくで使えてしまうのもモックの良いところなのですが、知っていれば「こんなこともできるかも」という発想が生まれます。

最後に、現場でよく使う基本パターンについて簡単に列挙します。

  • side_effect で処理を置き換える、または意図的に例外を出す
  • patch.object() ですでに import しているオブジェクトの属性をモックする
  • モックオブジェクトの assert_xx 系の関数 でアサーションする
  • モックオブジェクトの call_xx 系の属性 で呼び出し回数やその時の引数を知る
  • その時、wraps で元の関数は呼ばれるようにしておく
28
15
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
15

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?