概要
Pythonの標準モジュールである、unittest
を使ってテストコードを書く際、複数のテストケースをまとめてテストする方法としてsubtest
を使用することができます。
subtest
を使用する時、実行開始と同時にパッチを当てる方法に苦戦したので解決方法を紹介します。
結論だけを見たい方はこちらから飛べます。
まずsubtestのおさらいから
以下のような足し算を行うシンプルなメソッドがあるとします。
def tashizan(a: int, b: int) -> int:
"""
aとbの和を返す
"""
return a + b
このコードを複数パターンでテストする際、サブテストを使用するとこのようにまとめてテストを行うことができます。
import unittest
from sample import tashizan
class TestSample(unittest.TestCase):
def test_tashizan(self) -> None:
"""
sample.tashizanの実行結果をテスト
"""
test_cases = [
{'a': 1, 'b': 1, 'expect': 2},
{'a': 0, 'b': 3, 'expect': 3},
{'a': 3, 'b': -1, 'expect': 2},
]
for case in test_cases:
with self.subTest(a=case['a'], b=case['b']):
actual = tashizan(case['a'], case['b'])
self.assertEqual(actual, case['expect'])
このテストコードを実行すると、結果が期待通りであれば以下のように返ってきます。
python -m unittest -v ./test_sample.py
test_tashizan (test_sample.TestSample.test_tashizan)
sample.tashizanの実行結果をテスト ... ok
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
期待通りでなければ検知することができます。
python -m unittest -v ./test_sample.py
test_tashizan (test_sample.TestSample.test_tashizan)
sample.tashizanの実行結果をテスト ...
test_tashizan (test_sample.TestSample.test_tashizan) (a=3, b=-1)
sample.tashizanの実行結果をテスト ... FAIL
======================================================================
FAIL: test_tashizan (test_sample.TestSample.test_tashizan) (a=3, b=-1)
sample.tashizanの実行結果をテスト
----------------------------------------------------------------------
Traceback (most recent call last):
File "/test_sample.py", line 19, in test_tashizan
self.assertEqual(actual, case['expect'])
AssertionError: 2 != 0
----------------------------------------------------------------------
Ran 1 test in 0.000s
FAILED (failures=1)
サブテストの引数にテストで使用する変数を入力すると、テスト結果に表示されてデバックに便利です。
続いてMockのおさらい
本題の準備として、Mockを使ったテストコードについてもおさらいします。
テストする際にMockが必要な場合もあると思います。
先ほどのテスト対象を少し修正し、作り途中のメソッドを経由するとしましょう。
def heikin(a: int, b: int) -> int:
"""
aとbの平均を返す
"""
return tashizan(a, b) / 2
def tashizan(a: int, b: int) -> int:
"""
aとbの和を返す
"""
pass # 作成途中です...
このような場合でもMockを使用すれば作成したところまででテストを行うことができます。
import unittest
from unittest.mock import MagicMock, patch
from sample import heikin
class TestSample(unittest.TestCase):
@patch('sample.tashizan')
def test_heikin(self, mock: MagicMock) -> None:
"""
sample.heikinの実行結果をテスト
"""
a = 1
b = 3
mock.return_value = 4
expect = 2
actual = heikin(a, b)
self.assertEqual(actual, expect)
python -m unittest -v ./test_sample.py
test_heikin (test_sample.TestSample.test_heikin)
sample.tashizanの実行結果をテスト ... ok
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
では、subtestとMockを組み合わせてみましょう
先ほどとは少しコードを変えて、パターンによってMockされる部分が呼ばれたり、呼ばれなかったりするとします。
第1引数にTrueが入っていれば、MockをCallして平均値を返す。Falseが入っていればMockをCallせずにNoneを返すメソッドを作成してみます。
def heikin(flag: bool, a: int, b: int) -> int | None:
"""
aとbの平均を返す
"""
if flag:
return tashizan(a, b) / 2
else:
return None
def tashizan(a: int, b: int) -> int:
"""
aとbの和を返す
"""
return a + b
先ほどと同じ方法でPatchを当てて、引数の状態によってMockが呼ばれているか呼ばれていないかを確認するテストコードを書くとします。
import unittest
from unittest.mock import MagicMock, patch
from sample import heikin
class TestSample(unittest.TestCase):
@patch('sample.tashizan')
def test_heikin(self, mock: MagicMock) -> None:
"""
sample.tashizanの実行結果をテスト
"""
test_cases = [
{'flag': True, 'a': 1, 'b': 3, 'expect': 2},
{'flag': False, 'a': 0, 'b': 3, 'expect': None},
]
for case in test_cases:
with self.subTest(flag=case['flag'], a=case['a'], b=case['b']):
mock.return_value = case['a'] + case['b']
actual = heikin(case['flag'], case['a'], case['b'])
# フラグがTrueの場合はMockした部分が1度呼ばれているかどうか
if case['flag']:
mock.assert_called_once_with(case['a'], case['b'])
self.assertEqual(actual, case['expect'])
# フラグがFalseの場合はMockした部分が1度も呼ばれていないかどうか
else:
mock.assert_not_called()
self.assertEqual(actual, case['expect'])
では、これを実行するとどうなるか…
python -m unittest -v ./test_sample.py
test_heikin (test_sample.TestSample.test_heikin)
sample.tashizanの実行結果をテスト ...
test_heikin (test_sample.TestSample.test_heikin) (flag=False, a=0, b=3)
sample.tashizanの実行結果をテスト ... FAIL
======================================================================
FAIL: test_heikin (test_sample.TestSample.test_heikin) (flag=False, a=0, b=3)
sample.tashizanの実行結果をテスト
----------------------------------------------------------------------
Traceback (most recent call last):
File "test_sample.py", line 28, in test_heikin
mock.assert_not_called()
File ".pyenv/versions/3.11.7/lib/python3.11/unittest/mock.py", line 900, in assert_not_called
raise AssertionError(msg)
AssertionError: Expected 'tashizan' to not have been called. Called 1 times.
Calls: [call(1, 3)].
----------------------------------------------------------------------
Ran 1 test in 0.002s
FAILED (failures=1)
第1引数にTrueが入っていれば、MockをCallして平均値を返す。Falseが入っていればMockをCallせずにNoneを返す。しっかり表現できていそうですが、エラーが出てしまいました。
このコードの問題点はサブテスト内でMockを使い回してしまっていることです。
これを回避するためには、サブテスト毎にPatchを当て直してあげる必要があります。
サブテスト毎にPatchを当て直す
Pythonのwith文を使うとサブテストの実行とパッチを当てる作業を同時に行うことができます。
with self.subTest(), patch('sample.tashizan') as mock:
mock.return_value = 'example'
これを踏まえて先ほどのコードを書き直すと…
import unittest
from unittest.mock import MagicMock, patch
from sample import heikin
class TestSample(unittest.TestCase):
def test_heikin(self) -> None:
"""
sample.tashizanの実行結果をテスト
"""
test_cases = [
{'flag': True, 'a': 1, 'b': 3, 'expect': 2},
{'flag': False, 'a': 0, 'b': 3, 'expect': None},
]
for case in test_cases:
with self.subTest(flag=case['flag'], a=case['a'], b=case['b']), patch('sample.tashizan') as mock:
mock.return_value = case['a'] + case['b']
actual = heikin(case['flag'], case['a'], case['b'])
if case['flag']:
mock.assert_called_once_with(case['a'], case['b'])
self.assertEqual(actual, case['expect'])
else:
mock.assert_not_called()
self.assertEqual(actual, case['expect'])
python -m unittest -v ./test_sample.py
test_heikin (test_sample.TestSample.test_heikin)
sample.tashizanの実行結果をテスト ... ok
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
ちゃんと成功しました!
デコレータでパッチを当てることが多かったので、with文でも当てられることに気づきませんでした…
以上です。