mocker fixureを使用したテスト対象処理のMock化

# 本記事について

本記事では、ユニットテストの対象となるメソッド内で呼び出している各種メソッドを、pytestのmocker fixureを使用してpatchを適用し、テスト対象となる処理をMock化する場合の実装サンプルを記載しています。

# 本記事の作成環境

+ Ubuntu 20.04.3 LTS on WSL2
+ Python: 3.9.7
+ pytest: 7.1.2
+ pytest-mock: 3.7.0 

# patchの適用によるテスト対象のMock化

## クラスオブジェクトに対してpatchを適用

クラスオブジェクトに対して`mocker.patch()`、または`mocker.patch.object()`を使用して、patchを適用します。
クラスオブジェクトにpatchを適用するため、テストメソッドを終了するまでは、以降は常にMock化した値を返却します。

### クラスメソッド / 静的メソッド

#### テスト対象メソッド

`pathlib.Path`クラスのクラスメソッドである、`cwd()`にpatchを適用してテストを実施します。
本手順ではクラスメソッドにpatchを適用していますが、静的メソッドに対しても同様の手順でpatchの適用が可能です。

```python: sample/foo.py
from pathlib import Path

class Foo:
    @staticmethod
    def get_current_directory():
        return Path.cwd()
```

#### クラスオブジェクトを指定してpatchを適用

`pathlib.Path`クラスオブジェクトの`cwd()`メソッドをtargetに指定してpatchを適用します。
オブジェクトを対象とするため、`mocker.patch.object()`を使用します。

```python: tests/test_foo.py
# pathlib.Pathにpatchを適用するため、Pathのimportが必要
from pathlib import Path
from sample.foo import Foo

def test_get_current_directory(mocker):
    path = Path("current_dir/")
    # Pathクラスオブジェクトのcwd()にpatchを適用
    mocker.patch.object(Path, "cwd", return_value=path)

    assert Foo.get_current_directory() == path
```

#### メソッドを指定してpatchを適用

`pathlib.Path.cwd()`メソッドをtargetに指定してpatchを適用します。
メソッドを対象とするため、`mocker.patch()`を使用します。

```python: tests/test_foo.py
from sample.foo import Foo

def test_get_current_directory(mocker):
    path = Path("current_dir/")
    # sample.fooモジュールにimportされているPath.cwd()にpatchを適用
    mocker.patch("sample.foo.Path.cwd", return_value=path)

    assert Foo.get_current_directory() == path
```

### インスタンスメソッド

`pathlib.Path`クラスのインスタンスメソッドである、`is_file()`にpatchを適用してテストを実施します。

#### テスト対象メソッド

```python: sample/foo.py
from pathlib import Path

class Foo:
    def __init__(self):
        self._work_directory = Path("./work")
        self._work_file = self._work_directory / Path("work_file")

    def has_work_file(self):
        return self._work_file.is_file()
```

#### クラスオブジェクトを指定してpatchを適用

`pathlib.Path`クラスオブジェクトの`is_file()`メソッドをtargetに指定してpatchを適用します。
オブジェクトを対象とするため、`mocker.patch.object()`を使用します。

```python: tests/test_foo.py
# pathlib.Pathにpatchを適用するため、Pathのimportが必要
from pathlib import Path
from sample.foo import Foo

def test_has_file(mocker):
    # Pathクラスオブジェクトのis_file()にpatchを適用
    mocker.patch.object(Path, "is_file", return_value=True)

    foo = Foo()
    assert foo.has_work_file() == True
```

#### メソッドを指定してpatchを適用

`pathlib.Path.is_file()`メソッドをtargetに指定してpatchを適用します。
メソッドを対象とするため、`mocker.patch()`を使用します。

```python: tests/test_foo.py
from sample.foo import Foo

def test_has_file(mocker):
    foo = Foo()

    # sample.fooモジュールにimportされているPath.is_file()にpatchを適用
    mocker.patch("sample.foo.Path.is_file", return_value=True)

    assert foo.has_work_file() == True
```

### プロパティ

`pathlib.Path`クラスのプロパティである、`name`にpatchを適用してテストを実施します。

```python: sample/foo.py
from pathlib import Path

class Foo:
    def __init__(self):
        self._work_directory = Path("./work")
        self._work_file = self._work_directory / Path("work_file")

    def get_work_file_name(self):
        return self._work_file.name
```

#### クラスオブジェクトを指定してpatchを適用

`pathlib.Path`クラスオブジェクトの`name`プロパティをtargetに指定してpatchを適用します。
オブジェクトを対象とするため、`mocker.patch.object()`を使用します。
その際、プロパティに対してpatchを適用するため、`new_callable`で`PropertyMock`を渡します。

```python: tests/test_foo.py
# pathlib.Pathにpatchを適用するため、Pathのimportが必要
from pathlib import Path
from sample.foo import Foo

def test_get_work_file_name(mocker):
    foo = Foo()

    # Pathクラスオブジェクトのis_file()にpatchを適用
    # プロパティ
    mocker.patch.object(
            Path,
            "name",
            new_callable=mocker.PropertyMock,
            return_value="mocked_work_file")

    assert foo.get_work_file_name() == "mocked_work_file"
```

## インスタンスオブジェクトに対してpatchを適用

インスタンスオブジェクトに対しても、クラスオブジェクトと同様に`mocker.patch()`、または`mocker.patch.object()`を使用してpatchを適用することが可能です。
しかし、実際にインスタンスオブジェクトに対してpatchを適用しようとすると、エラーが発生してpatchを適用できない場合があります。[^1]
その場合は、patchを適用するインスタンスオブジェクトをMockに置き換え、patchを適用します。
インスタンスオブジェクトをMockに置き換えているため、そのインスタンスオブジェクトのみMock化した値を返却します。

### インスタンスメソッド

`pathlib.Path`クラスのインスタンスメソッドである、`is_file()`にpatchを適用してテストを実施します。

#### テスト対象メソッド

```python: sample/foo.py
from pathlib import Path

class Foo:
    def __init__(self):
        self._work_directory = Path("./work")
        self._work_file = self._work_directory / Path("work_file")

    def has_work_file(self):
        return self._work_file.is_file()
```

#### インスタンスオブジェクトをMockに置き換えてpatchを適用

テストで`is_file()`メソッドを呼び出す`pathlib.Path`インスタンスオブジェクトを`MagicMock`に置き換え、置き換えたMockの`is_file()`メソッドをtargetに指定してpatchを適用します。
オブジェクトを対象とするため、`mocker.patch.object()`を使用します。

```python: tests/test_foo.py
from sample.foo import Foo

def test_has_file(mocker):
    foo = Foo()

    # foo._work_fileをMagicMockに置き換え、is_file()にpatchを適用
    foo._work_file = mocker.MagicMock()
    mocker.patch.object(
            foo._work_file,
            "is_file",
            return_value=True)

    assert foo.has_work_file() == True
```

### プロパティ

`pathlib.Path`クラスのプロパティである、`name`にpatchを適用してテストを実施します。

```python: sample/foo.py
from pathlib import Path

class Foo:
    def __init__(self):
        self._work_directory = Path("./work")
        self._work_file = self._work_directory / Path("work_file")

    def get_work_file_name(self):
        return self._work_file.name
```

#### インスタンスオブジェクトのプロパティをPropertyMockに置き換え

テストで`name`プロパティを呼び出す`pathlib.Path`インスタンスオブジェクトを`MagicMock`に置き換え、置き換えたMockの`name`プロパティにPropertyMockを設定します。

```python: tests/test_foo.py
from sample.foo import Foo

def test_get_work_file_name(mocker):
    foo = Foo()

    # foo._work_fileをMagicMockに置き換え
    foo._work_file = mocker.MagicMock()
    # foo._work_fileのnameプロパティにPropertyMockを設定
    type(foo._work_file).name = mocker.PropertyMock(return_value="mocked_value")

    assert foo.get_work_file_name() == "mocked_value"
```

### インスタンス生成操作

インスタンスオブジェクトをテスト対象メソッド内で生成している場合は、代入によりpatchを適用するインスタンスオブジェクトをMockに置き換えることができません。
この場合は、インスタンス生成操作に対してMockを返却するようにpatchを適用します。
patchはクラスオブジェクトに対して適用されるため、テストメソッドを終了するまでは、以降に生成するすべてのインスタンスはMockとなります。

#### テスト対象メソッド

```python: sample/foo.py
from pathlib import Path

class Foo:
    @staticmethod
    def is_file_exist(path):
        # pathlib.Pathクラスのインスタンスオブジェクトを生成し、is_file()を呼び出し
        return Path(path).is_file()
```

#### インスタンス生成操作を指定してpatchを適用

`pathlib.Path`クラスのインスタンス生成操作をtargetに指定してpatchを適用します。
インスタンス生成操作を対象とするため、`mocker.patch()`を使用します。

```python: tests/test_foo.py
from sample.foo import Foo

def test_is_file_exist(mocker):
    # Path.is_file()のpatchを適用したMagicMockを作成
    mocked_path = mocker.MagicMock()
    mocker.patch.object(mocked_path, "is_file", return_value=True)

    # sample.fooモジュールにimportされているPathインスタンスオブジェクトの生成操作にpatchを適用
    # インスタンス生成操作はPathクラスのメソッドではないため、mocker.patch.object() では適用できない
    mocker.patch("sample.foo.Path", return_value=mocked_path)

    assert Foo.is_file_exist("/path/to/file/file_name") == True
```

# 参考
https://docs.python.org/ja/3/library/unittest.mock.html

https://docs.pytest.org/en/7.1.x/index.html

https://pytest-mock.readthedocs.io/en/latest/

[^1]: patchを適用するクラスが`__slots__`を定義している場合は、patchを適用するメソッド（属性）がread onlyとなるため、patchを適用できない。