概要
Python の unittest を使っていて、mock が何をするものかは分かっているけど、まだちょっと得体が知れない、怖い、という段階があると思います。
この段階を克服するために、何も知らない状態から徐々に mock を理解するためのステップを作りたいと思いました。
対象は Python 3.x です。
どんな時にモックしたいか
単体テストでは、個々の機能を切り離してテストしたいものです。
たとえば A という関数をテストしたい時、A の中で B という関数を呼んでいるとします。
テストしたいのは A の機能なので、B にバグがあっても A にバグがなければテストはパスさせたいです。
また、このテストの期待結果を決めるのに、B からの返り値が予想または操作できると嬉しいです。
そこで、テスト中だけは B を呼ぶ代わりに、期待通りに動く操り人形のようなものを呼ぶようにします。
この操り人形を B のモックといいます。
また、このように呼び出し先をすげ替えることを「モックする」と言ったりします。
具体例
具体例として、チケット購入サイトにあるような「空席状況」を表示するプログラムを考えます。
といっても難しいことは無くて、残席数によって「完売」「残り僅か」「余裕あり」のいずれかを返すだけです。
イメージしやすいように Event
というクラスを作って、空席状況を返す関数 (関数 A) を定義しましょう。
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) を呼んで取得しています。
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
動作確認
動作を見るには同じフォルダから以下のようなプログラムを実行します。
from my_class import Event
# Event を作る
event = Event(id=1)
# 空席状況を取得する
availability = event.check_availability()
print(availability)
出力はこのようになります。
> python ./main.py
余裕あり
ふつうにテストを書いてみる
関数を呼んで期待した結果が返ってくることを確認する、という「ふつうのテスト」を unittest で書いてみます。
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()
を呼んだ時に、モックが呼ばれるようにすれば良いのです。
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
で関数を定義した時、関数の実体とそれを呼ぶための名前が作られます。
2. my_class に import
関数 (の名前) を my_class
モジュールに import
すると、その名前空間にコピーが作られます。
コピー元と同じ関数を指しますが、あくまでコピーです。
関数 A は、このコピーの方を使って関数 B を呼びます。
3. テストコードに import
これをさらにテストコードに import
すると以下のようになります。
関数 A が呼んでいるものをすげ替えたいので、これで良いようにも思えますが…。
4. モックすると…
なんと、これをモック用の関数にすげ替えても、関数 A から呼ばれる関数 B は変わりません。
テストコードに import
したものは、その時点でコピーになっていたからです。
テストをしている名前空間は tests.test01 ですが、関数 A は相変わらず my_class にいるので、そっちの方を書き換えないといけなかったのです。
正しい import とモックのしかた
正しくモックするには、「自力でモックしてみる」の最初のコードのように my_class
モジュールを import
します。
すると、my_class
という「モジュールを指すポインタ」がコピーされます。
これを使って my_class.get_available_seats
と書けば、my_class
モジュールの中の get_available_seats
(コピーでなくそのもの) にアクセスできます。
モック用の関数をそこに代入すると以下のようになり、すげ替えが上手くいきます。
何を 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
を使った場合の書き方は、このようになります。
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
は属性なので、オブジェクトを作った後で設定したり変更したりできます。
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, '残り僅か')
どうなっているのか
使い方は分かっても、この「属性を設定するだけで返り値が変わる」という動作が気持ち悪いと、この先の理解がスムーズにいかないかも知れません。
蛇足かもしれませんが、これがどういう仕組みなのかを想像するために、自力でモックオブジェクトを作ってみます。
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
クラス というのが出てくることがありますが、これはあまり気にしなくても大丈夫だと思います。
MagicMock
が Mock
の拡張版なのですが、あえて 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
文 で使う方法を見てみます。
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
ブロックではなく関数やクラスのスコープで一時的にモックすることができます。
次のコードは先ほどと同じことをデコレータで書いた例です。
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
で元の関数は呼ばれるようにしておく