Pythonのunittestでサブテスト開始と同時にPatchを当てる

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

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

結論だけを見たい方は[こちら](#サブテスト毎にpatchを当て直す)から飛べます。

## まずsubtestのおさらいから
以下のような足し算を行うシンプルなメソッドがあるとします。
```python:sample.py
def tashizan(a: int, b: int) -> int:
    """
    aとbの和を返す
    """
    return a + b
```

このコードを複数パターンでテストする際、サブテストを使用するとこのようにまとめてテストを行うことができます。
```python: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'])
```

このテストコードを実行すると、結果が期待通りであれば以下のように返ってきます。
```zsh:実行結果
python -m unittest -v ./test_sample.py

test_tashizan (test_sample.TestSample.test_tashizan)
sample.tashizanの実行結果をテスト ... ok

----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
```

期待通りでなければ検知することができます。
```zsh:実行結果
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が必要な場合もあると思います。
先ほどのテスト対象を少し修正し、作り途中のメソッドを経由するとしましょう。
```python: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を使用すれば作成したところまででテストを行うことができます。
```python: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)

```
```zsh:実行結果
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を返すメソッドを作成してみます。
```python: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が呼ばれているか呼ばれていないかを確認するテストコードを書くとします。
```python: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'])

```
では、これを実行するとどうなるか…

```zsh:実行結果
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文を使うとサブテストの実行とパッチを当てる作業を同時に行うことができます。

```python
with self.subTest(), patch('sample.tashizan') as mock:
    mock.return_value = 'example'
```
これを踏まえて先ほどのコードを書き直すと…

```python: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'])

```
```zhs:実行結果
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文でも当てられることに気づきませんでした…
以上です。