1
0

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でサブテスト開始と同時にPatchを当てる

Last updated at Posted at 2025-02-12

概要

Pythonの標準モジュールである、unittestを使ってテストコードを書く際、複数のテストケースをまとめてテストする方法としてsubtestを使用することができます。

subtestを使用する時、実行開始と同時にパッチを当てる方法に苦戦したので解決方法を紹介します。

結論だけを見たい方はこちらから飛べます。

まずsubtestのおさらいから

以下のような足し算を行うシンプルなメソッドがあるとします。

sample.py
def tashizan(a: int, b: int) -> int:
    """
    aとbの和を返す
    """
    return a + b

このコードを複数パターンでテストする際、サブテストを使用するとこのようにまとめてテストを行うことができます。

test_sample.py
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が必要な場合もあると思います。
先ほどのテスト対象を少し修正し、作り途中のメソッドを経由するとしましょう。

sample.py
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を使用すれば作成したところまででテストを行うことができます。

test_sample.py
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を返すメソッドを作成してみます。

sample.py
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が呼ばれているか呼ばれていないかを確認するテストコードを書くとします。

test_sample.py
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'

これを踏まえて先ほどのコードを書き直すと…

test_sample.py
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文でも当てられることに気づきませんでした…
以上です。

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?