pytest（主に pytes-mock）のモックについて、ややこしいと思ったこと

# はじめに

pythonとユニットテストについて学び始めたのでメモを残す。
誤りや意見などあれば、是非お願いします。

今回は、`os`モジュールを例にモックするパターンを考えていこうと思う。
大きく2つに分けて、下記パターンを考える。

1. `mocker.patch()`でモジュールの対象メソッドをモックする
2. `mocker.patch.object()`でモジュールの対象メソッドをモックする
3. (オプション)`monkeypatch.setattr()`でモックする

また、`from ~ import`を利用する場合の考慮点も書いてみる。
boto3 モジュールのモックについても余力があれば書きたい。

## 学習する中で思ったこと
pythonのユニットテストは、そこそこ種類（名前が違うだけで動作はほぼ同じはず）があり混乱した。
この[stackoverflow](https://stackoverflow.com/questions/70888992/unittest-mock-vs-mock-vs-mocker-vs-pytest-mock)の内容に共感した。

## テスト関係のモジュール

- pytest
- pytest-mock

# 確認したこと
とりあえず、実装パターンを下記に示す。
どれでも`os.path.join`をモックできているはず。。。
モジュールをモックする場合の使い分けが理解できていない。（違いについて教えてくれると喜びます。

## `mocker.patch()`でモジュールの対象メソッドをモックする
テストの意味は考えず、適当なコードで確認する。

- hoge.py: テスト対象ファイル
- test_hoge.py: テストコードファイル

```python
# test_hoge.py
import pytest
import hoge

@pytest.fixture
def mock_os_path_join(mocker):
    # joinをパッチ
    mocker.patch('os.path.join', return_value='/x/y/z') # mocker.patchを使用

def test_hoge(mock_os_path_join):
    path = hoge.func('a/b')
    assert path == '/x/y/z'
```

```python
# hoge.py
import os

def func(dir):
    path = os.path.join(dir, 'b/d')
    return path
```

# `mocker.patch.object()`でモジュールの対象メソッドをモックする

```python
# test_hoge.py
import pytest
import hoge
import os

@pytest.fixture
def mock_os_path_join(mocker):
    # joinをパッチ
    mocker.patch.object(os.path, 'join', return_value='/z/y/x') # mocker.patch.objectを使用

def test_hoge(mock_os_path_join):
    path = hoge.func('a/b')
    assert path == '/z/y/x'
```

## (オプション)`monkeypatch.setattr()`でモックする

```python
# test_hoge.py
import pytest
from pytest_mock import mocker
import hoge
import os


@pytest.fixture
def mock_os_path_join(monkeypatch):
    def mock_join(*args, **kwargs):
        return '/z/y/x'
    # monkeypatch.setattrを使用
    monkeypatch.setattr(os.path, 'join', mock_join)
    
    # monkeypatch.setattr(os.path, 'join', mocker.Mock(return_value='/z/y/x'))
    # monkeypatchにMockオブジェクトを使うことで、return_valueを動的にセット可能
    # mocker単体で上記仕組みは実装できるので、今回だとmonkeypatchを使用する意味はほぼないと思う

def test_hoge(mock_os_path_join):
    path = hoge.func('a/b')
    assert path == '/z/y/x'
```
Mockオブジェクトを使うと、`return_value`や`side_effect`で色々できるのが便利だと思う。


# from ~ importを使用したモジュールをモックする場合
個人的にこのパターンは面倒くさいと思うのでメモする。
`from os.path import join`こんな感じでインポートしたとき、テストコード側も変わる。
対象のモジュールがオブジェクトとしてインポートされていることを意識することが大事。

[引用](https://note.crohaco.net/2015/python-mock/#mock.patch.object)
> from import によって利用可能になったモジュール変数はもとのモジュールから切り離されるため、 もとのモジュールをパッチしても書き換えられません。

```python
# hoge2.py
from os.path import join
print(f'joinオブジェクトが属するモジュール名： {__name__}')

def func(dir):
    path = join(dir, 'b/d')
    # print(path)
    return path
```

mocker.patchを使用

```python
# test_hoge2.py 
import pytest
import hoge2

@pytest.fixture
def mock_os_path_join(mocker):
    # hoge2.pyでインポートしたjoinオブジェクトが属するモジュールを指定する必要がある
    mocker.patch('hoge2.join', return_value='/x/y/z')

def test_hoge(mock_os_path_join):
    path = hoge2.func('a/b')
    assert path == '/x/y/z'
```

mocker.patch.objectを使用。
fixtureでhoge3をimportする必要はなかったと思う。

```python
# test_hoge3.py
import pytest

@pytest.fixture
def hoge3():
    import hoge3
    return hoge3

@pytest.fixture
def mock_os_path_join(mocker, hoge3):
    mocker.patch.object(hoge3.path, 'join', return_value='/z/y/x')

def test_hoge(mock_os_path_join, hoge3):
    path = hoge3.func('a/b')
    assert path == '/z/y/x'
````

```python
# hoge3.py
from os import path

def func(dir):
    ret = path.join(dir, 'b/d')
    return ret
```


# boto3モジュールをモックする場合
そもそも`moto`モジュールがモック対象をサポートしているなら、`moto`を使うほうがよいはず。
今回は、`mocker.patch`系統を使う場合について考える。
どのレベルでモックするべきか迷ったので、メモする。

## `boto3.client()`の`client`をモックする
この単位でモックするのは好ましくないと思う。`client`関数全体をモックすると、他への影響が大きそう。

```python
# app.py
import boto3

translate_cliet = boto3.client('translate')

def main():
    print(translate_cliet)
    res = translate_cliet.translate_text(
        Text='おはよう',
        SourceLanguageCode='ja',
        TargetLanguageCode='en',
    )
    print(res)
    return res
```

```python
# test_app.py
import boto3
import pytest

@pytest.fixture
def app():
    import app
    return app

@pytest.fixture
def mock_boto3_client(mocker):
    mock_client = mocker.Mock()
    mock_client().translate_text.return_value = '翻訳結果のオブジェクト'
    mocker.patch('boto3.client', mock_client)

def test_app(mock_boto3_client, app):
    assert app.main() == '翻訳結果のオブジェクト'
```

## `boto3.client()`から生成したオブジェクトをモックする
app.pyの`translate_client`変数をモックしているので、範囲が最小限。

```python
import boto3
import pytest


@pytest.fixture
def app():
    import app
    return app


@pytest.fixture
def mock_boto3_client(mocker, app):
    translate_client = boto3.client('translate')
    mock_client = mocker.Mock(wraps=translate_client)
    mock_client.translate_text.return_value = '翻訳結果のオブジェクト'
    mocker.patch.object(app, 'translate_cliet', mock_client)


def test_app(mock_boto3_client, app):
    assert app.main() == '翻訳結果のオブジェクト'
```

# おわりに
単語の理解が曖昧だと思った。「モックする」と「パッチする」のどっちが正しいのか、書きながらわからなくなったので後で整理する。
`Mock`と`MagicMock`の違いや、キーワード引数の動作についても調べる必要があると感じる。
最後に、pytestを学習する際は一緒に[unittest.mock](https://docs.python.org/ja/3/library/unittest.mock-examples.html#patch-decorators)を調べるのが大事だと思った。