pytestのmockerについて

この記事は [レコチョク Advent Calendar 2023](https://qiita.com/advent-calendar/2023/recochoku) の7日目の記事となります。

### はじめに

株式会社レコチョクの齋藤拓海です。

最近は登山にハマってます。スノボもこれから楽しみです。
最近ハマっているアーティストはpeople 1、キタニタツヤです。

さて、皆さんは普段テストを書いているでしょうか。
自分は現在pythonで開発をしていて、単体テストはpytest を使用しています。
単体テストを記載していて困るのが、外部APIを使用している部分やDBなどだと思います。
そこでmockが便利なので、今回は紹介したいと思います。

## mockの種類

pythonのmockはpytestのmockとunittestのmockとで２種類あります。

それぞれ特徴を簡単にまとめてみました。

| 特性/フレームワーク | pytest-mock (mocker) | unittest.mock (Mock, patch, etc.) |
| --- | --- | --- |
| インストール | 別途 pytest-mock プラグインをインストールが必要 | Python 標準ライブラリの一部として組み込まれている |
| 使用の簡単さ | pytest フィクスチャを通じて簡単にアクセスできる | デコレータ、コンテキストマネージャ、または手動で Mock をセットアップする |
| フレームワーク統合 | pytestに完全に統合されている | unittestと完全に統合されているが、他のテストフレームワークでの使用も可能 |
| APIスタイル | よりpytestスタイルに適したインターフェース | Python標準のunittestスタイルのインターフェース |
| コミュニティとの連携 | pytestのエコシステムとの強い連携 | Python コミュニティ全体からの幅広いサポート |
| 機能 | unittest.mockに比べて限られているが、多くのテストケースにおいて十分 | より多機能で、複雑なモッキングが可能 |
| 学習曲線 | pytestに慣れているユーザーにとって簡単 | Mockオブジェクトとパッチングの仕組みに慣れる必要あり |
| 柔軟性 | pytestフィクスチャと組み合わせることで高い柔軟性を提供 | 接続されたオブジェクトの視覚化や制御がより詳細に可能 |

２種類あるとはいえ、pytest-mockはunittestのmockをラップしたものなので、ほとんど同じ感覚で使用できます。

なので、今回はpytest-mockについて話していきます。

## mockerの基本：モックの生成と適用方法

mockerを利用すると、スタブ、パッチといったテストダブルを生成し、特定のメソッドや関数の挙動を模倣または制御することが可能となります。

コード内で外部APIを呼び出したり、DBなどミドルウェアと接続したりする部分に使用することで単体テストが可能になります。

### スタブの生成と適用

スタブは主に固定の値を返すために使用します。以下の例では、スタブを使用して外部APIからのレスポンスを模倣しています。

```python
def test_get_weather(mocker):
    stub_get_weather = mocker.stub(name='get_current_weather')
    stub_get_weather.return_value = {'temperature': 20}
    # ここでget_current_weather関数を呼び出すと、{'temperature': 20}を返します

```

### パッチの生成と適用

パッチは特定のメソッドや関数の実装を一時的に置き換えるために使用します。
以下の例では、mocker.patchメソッドを使用して'weather.api.get_current_weather'関数の実装を一時的に変更しています。

```python
def test_get_weather(mocker):
    mock_get_weather = mocker.patch('weather.api.get_current_weather', return_value={'temperature': 20})
    # ここでget_current_weather関数を呼び出すと、{'temperature': 20}を返します

```

ほとんど変わりませんがpatchの方が拡張性が高いと感じるので自分はpatchを普段使っています。

## mockerの応用：戻り値のカスタマイズ、例外のハンドリング、メソッド呼び出しの確認

mockerは更に、戻り値のカスタマイズ、例外のハンドリング、メソッド呼び出しの確認など、テストの高度な操作も可能にします。

### 戻り値のカスタマイズ

return_valueを使うと、モックの戻り値を自由に設定することができます。
以下の例では、モックが呼び出されたときに特定の値を返すように設定しています。

```python
def test_get_weather(mocker):
    mock_get_weather = mocker.patch('weather.api.get_current_weather')
    mock_get_weather.return_value = {'temperature': 20}
    # ここでget_current_weather関数を呼び出すと、{'temperature': 20}を返します

```

### 例外のハンドリング

side_effectを使うと、モックが呼び出されたときに特定の例外を送出するように設定することも可能です。
これにより、例外のハンドリングのテストが容易になります。

```python
def test_get_weather(mocker):
    mock_get_weather = mocker.patch('weather.api.get_current_weather')
    mock_get_weather.side_effect = Exception('API request failed')
    # ここでget_current_weather関数を呼び出すと、'API request failed'というメッセージの例外が送出されます

```

### メソッド呼び出しの確認

assert_called_once_withを使うと、モックが期待通りに呼び出されたかどうかを確認することができます。
これにより、コードが想定通りの動作を行っているかを確認できます。

```python
def test_get_weather(mocker):
    mock_get_weather = mocker.patch('weather.api.get_current_weather')
    # 何らかの処理...
    mock_get_weather.assert_called_once_with('Tokyo')
    # get_current_weather関数が一度だけ呼び出され、そのときの引数が'Tokyo'であることを確認します

```

他にもたくさんあります。詳しくは[公式ドキュメント](https://docs.python.org/ja/3.12/library/unittest.mock.html)をご参照ください。

## AIを利用したテストケースの作成

ちょっと物足りないので、テストケース作成をAIに行ってもらいました。

プロンプト

```jsx
pytestのmockerを使ったテストを作成してください。
対象のpythonコードは外部APIを呼び出すコードで、外部APIをmock化してください。
テストケースとして
1. 正常系
2. 異常系（外部APIでエラー）
を作成してください。
```

出力結果(try&error済み)

↓

モックを利用したpytestのテストケースを示します。

まず、テスト対象のPythonコードとして以下のような外部APIを呼び出す関数を仮定します。

```python
# weather.py
import requests

def get_current_weather(city):
    response = requests.get(f'http://api.weatherapi.com/v1/current.json?key=YOUR_API_KEY&q={city}')
    return response.json()

```

この関数が外部API（ここでは天気情報API）を呼び出し、レスポンスをjson形式で返すものとします。

この関数のテストをmockerを使って作成します。以下に正常系と異常系のテストケースを示します。

```python
# test_weather.py
import pytest
import requests
from weather import get_current_weather

def test_get_current_weather_success(mocker):
    # モックの設定
    mock_response = mocker.Mock()
    mock_response.json.return_value = {"location": {"name": "Tokyo"}, "current": {"temp_c": 20}}
    mocker.patch('requests.get', return_value=mock_response)

    # テスト対象の関数を呼び出す
    result = get_current_weather('Tokyo')

    # 戻り値の検証
    assert result == {"location": {"name": "Tokyo"}, "current": {"temp_c": 20}}

def test_get_current_weather_failure(mocker):
    # モックの設定
    mocker.patch('requests.get', side_effect=requests.exceptions.HTTPError)

    # テスト対象の関数を呼び出す際に例外が送出されることを検証
    with pytest.raises(requests.exceptions.HTTPError):
        get_current_weather('Tokyo')

```

上記のテストケースでは、`requests.get`メソッドの挙動をモック化しています。
正常系ではAPIからのレスポンスを模倣し、異常系ではAPIからのエラーレスポンスを模倣しています。

実際に実行してエラーが出たときは下記のプロンプトで修正しました。

```jsx
下記の#コード を実行した結果、下記の#エラー が出ました。
原因特定して、コードを修正して出力してください。
#コード
"""
# ここにソースコードをコピペ
"""

#エラー
"""
# ここにエラーメッセージをコピペ
"""
```

このように最近はテストも書いてくれるので、もし苦労していたら活用してみてください。


明日の [レコチョク Advent Calendar 2023](https://qiita.com/advent-calendar/2023/recochoku) は8日目＜レコチョクチケット（NFT）のエスノグラフィー調査報告＞です。

この記事はレコチョクのエンジニアブログの記事を転載したものとなります。

https://techblog.recochoku.jp/9869