pytestを今更ながらまとめてみる

# はじめに
今年こそはダイエットを心に決めた、ノベルワークスのりょうちん（[ryotech34](https://x.com/ryotech34)）です。
cursorを使用してpytestでテストを書かせた際に、知らないデコレータなど知識不足が目についたので改めてpytestについてまとめてみました。

https://docs.pytest.org/en/latest/contents.html

# 対象読者
- pytestにあまり触れたことがない方
- cursorで初めてpytestを使うよっていう方
  
# 今回話さないこと
- セットアップと基本的な実行方法
- unittestなどの別ライブラリとの比較

# pytestとは
pythonでテストコードを書く際に用いられる外部ライブラリです。
簡単なコードサンプル集は寺田さんの記事に詳しくまとめられていました。

https://qiita.com/waterada/items/6143d80896eb9d89bf2f

今回は、
- よく使う便利な機能
- cursorに改めて指示しないと別のやり方で実行してしまう機能

をピックアップしました。

## fixture
fixtureとは、テストの前処理や後処理など「テストに必要な環境（状態）のセットアップ・クリーンアップ」を簡潔に行うための仕組みです。

https://docs.pytest.org/en/latest/how-to/fixtures.html

よく使用されるケースとして
- ダミーデータの作成
- 一時ファイルの作成と削除
- DBへの接続とクローズ

などが挙げられます。
### サンプルコード
今回は`UserRepository`というユーザー情報をDBとやり取りするクラスで、ユーザーが存在するか確認を確認する`exists`のテストケースです。
```py
import pytest
import logging

# テストしたいクラス
class UserRepository:
    def __init__(self):
        self.users = []

    def create(self):
        user = {"id": len(self.users) + 1}
        self.users.append(user)
        return user

    def delete(self, user):
        self.users.remove(user)

    def exists(self, user):
        return user in self.users

@pytest.fixture
def userRepository():
    return UserRepository()

@pytest.fixture
def create_user(userRepository):
    # 前処理：ユーザーの作成
    user = userRepository.create()
    logging.info("Created user")
    logging.debug(f"Created user: {user}")
    yield user
    # 後処理：ユーザーの削除
    userRepository.delete(user)
    logging.info("Deleted user")

def test_create_user(create_user, userRepository):
    # テスト内容：ユーザーが存在するか確認
    logging.info("Checking if user exists")
    assert userRepository.exists(create_user), "User does not exist"
```
### 実行結果
コードにおける`yield`の前後でlogが分割されていることが確認できます。
```sh
------------------------------------------ live log setup ------------------------------------------
2025-01-19 05:31:35 - root - INFO - Created user
2025-01-19 05:31:35,521 - root - DEBUG - Created user: {'id': 1}
2025-01-19 05:31:35 - root - DEBUG - Created user: {'id': 1}
2025-01-19 05:31:35,521 - root - INFO - Checking if user exists
------------------------------------------ live log call -------------------------------------------
2025-01-19 05:31:35 - root - INFO - Checking if user exists
PASSED2025-01-19 05:31:35,521 - root - INFO - Deleted user

---------------------------------------- live log teardown -----------------------------------------
2025-01-19 05:31:35 - root - INFO - Deleted user
```
1. setup（前処理）
2. call（テスト）
3. teardown（後処理）

の順番でテストが実行されていることが確認できました。

## モック
pytestではunittestも使用でき、モックを作成する際にunittestの方が良いのかpytestの`pytest-mock`の方が良いのかで迷いました。
nyanchuさんの記事がとても綺麗にまとめられています。

https://zenn.dev/nyanchu/articles/pytest_mock_8da0886bbb9087

記事内にMagicMockとpytest-mockerを比較した表がまとめられていました。

おそらく`pytest-mock`を使用した方が楽なケースがほとんどだとぼんやり想像しています。

### サンプルコード
```py
async def mock_get_weather(mocker):
    expected_weather = {
        "city": "Tokyo",
        "temperature": 25,
        "condition": "cloudy"
    }
    mock = mocker.patch('tests.sample.src.app.WeatherAPI.get_weather', return_value=expected_weather)
    logging.info(f"Mocked get_weather with {expected_weather}")
    return mock, expected_weather
```

1. モックを作成したい関数に`mocker.patch`で指定して置き換える
2. `return_value`をセットする

ざっくりですが。この流れでmockが作成できることが確認できました。

## asyncio
FastAPIやその他機能を実装する際に、`async/await`を使用することがよくあると思います。
asyncについてはこちらに詳しくまとめられています。

https://qiita.com/waterada/items/1c03a7c863faf9327595

### pytestでは

実際のアプリケーションで非同期で動作しているならば、テストも非同期で実装されていなければなりません。
pytestで非同期処理を実装するにはプラグインツールである`pytest-asyncio`をインストールする必要があります。
```sh
pip install pytest-asyncio
```
https://pypi.org/project/pytest-asyncio/

コードの違いは以下の通りです。

### asyncなし
```py
@pytest.fixture
async def mock_get_weather(mocker):
    expected_weather = {
        "city": "Tokyo",
        "temperature": 25,
        "condition": "cloudy"
    }
    mock = mocker.patch('tests.sample.src.app.WeatherAPI.get_weather', return_value=expected_weather)
    logging.info(f"Mocked get_weather with {expected_weather}")
    return mock, expected_weather

async def test_get_weather_success(test_client, mock_get_weather):
    mock, expected_weather = mock_get_weather
    city = expected_weather["city"]
    
    # テストの実行
    response = test_client.get(f"/weather/{city}")
    logging.info(f"Received response: {response.json()}")
    
    assert response.status_code == 200
    assert response.json() == expected_weather
    mock.assert_called_once()
```
awaitしてないよと怒られてる。
```sh
py:142: RuntimeWarning: coroutine 'mock_get_weather' was never awaited
```
### asyncあり
デコレータが変化
- `@pytest.fixture` → `@pytest_asyncio.fixture`
- `@pytest.mark.asyncio`を追加
  
```py
@pytest_asyncio.fixture
async def mock_get_weather(mocker):
    expected_weather = {
        "city": "Tokyo",
        "temperature": 25,
        "condition": "cloudy"
    }
    mock = mocker.patch('tests.sample.src.app.WeatherAPI.get_weather', return_value=expected_weather)
    logging.info(f"Mocked get_weather with {expected_weather}")
    return mock, expected_weather

@pytest.mark.asyncio
async def test_get_weather_success(test_client, mock_get_weather):
    mock, expected_weather = mock_get_weather
    city = expected_weather["city"]
    
    # テストの実行
    response = test_client.get(f"/weather/{city}")
    logging.info(f"Received response: {response.json()}")
    
    assert response.status_code == 200
    assert response.json() == expected_weather
    mock.assert_called_once()
```
これでOK!

## FastAPIを使ったサンプルコード
今回はFastAPIのテストクライアントを使用して、weatherAPIを使ったシステムのテストをしてみます。
テストクライアントについては下記を参照ください。

https://fastapi.tiangolo.com/ja/tutorial/testing/
### フォルダ構成
```md
tests/
└── sample/
    ├── src/
    │   ├── app.py
    │   └── weatherAPI.py
    └── tests/
        └── test_app.py
```
### app.py

```py:app.py
from fastapi import FastAPI, HTTPException
from tests.sample.src.weatherAPI import WeatherAPI

app = FastAPI()

@app.get("/weather/{city}")
async def get_weather(city: str):
    try:
        api = WeatherAPI()
        return await api.get_weather(city)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.put("/weather/{city}")
async def update_weather(city: str, data: dict):
    try:
        api = WeatherAPI()
        return await api.update_weather(city, data)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

```

### weatherAPI
```py:weatherAPI.py
class WeatherAPI:
    def __init__(self):
        self.cached_weather = {}

    async def get_weather(self, city: str):
        self.cached_weather[city] = city
        return city

    async def update_weather(self, city: str, data: dict):
        self.cached_weather[city] = data
        return city
```

### test_app.py
```py:test_app.py
import pytest
import pytest_asyncio
import logging
from fastapi.testclient import TestClient
from tests.sample.src.app import app
from tests.sample.src.weatherAPI import WeatherAPI

@pytest.fixture
def test_client():
    return TestClient(app)

@pytest_asyncio.fixture
async def mock_get_weather(mocker):
    expected_weather = {
        "city": "Tokyo",
        "temperature": 25,
        "condition": "cloudy"
    }
    mock = mocker.patch('tests.sample.src.app.WeatherAPI.get_weather', return_value=expected_weather)
    logging.info(f"Mocked get_weather with {expected_weather}")
    return mock, expected_weather

@pytest_asyncio.fixture
async def mock_update_weather(mocker):
    new_weather = {
        "city": "Osaka",
        "temperature": 30,
        "condition": "rainy"
    }
    mock = mocker.patch('tests.sample.src.app.WeatherAPI.update_weather', return_value=new_weather)
    logging.info(f"Mocked update_weather with {new_weather}")
    return mock, new_weather

@pytest.mark.asyncio
async def test_get_weather_success(test_client, mock_get_weather):
    mock, expected_weather = mock_get_weather
    city = expected_weather["city"]
    
    # テストの実行
    response = test_client.get(f"/weather/{city}")
    logging.info(f"Received response: {response.json()}")
    
    assert response.status_code == 200
    assert response.json() == expected_weather
    mock.assert_called_once()

@pytest.mark.asyncio
async def test_update_weather_success(test_client, mock_update_weather):
    mock, new_weather = mock_update_weather
    city = new_weather["city"]
    
    # 天気を更新
    update_response = test_client.put(
        f"/weather/{city}",
        json=new_weather
    )
    logging.info(f"Updated weather: {update_response.json()}")
    
    assert update_response.status_code == 200
    assert update_response.json() == new_weather
    mock.assert_called_once()
```

### テスト実行
```sh
cd sample
pytest tests
```

### 実行結果
```sh
------------------------------------------ live log setup ------------------------------------------
2025-01-19 08:48:55 - asyncio - DEBUG - Using selector: KqueueSelector
2025-01-19 08:48:55,764 - asyncio - DEBUG - Using selector: KqueueSelector
2025-01-19 08:48:55 - asyncio - DEBUG - Using selector: KqueueSelector
2025-01-19 08:48:55,766 - root - INFO - Mocked get_weather with {'city': 'Tokyo', 'temperature': 25, 'condition': 'cloudy'}
2025-01-19 08:48:55 - root - INFO - Mocked get_weather with {'city': 'Tokyo', 'temperature': 25, 'condition': 'cloudy'}
2025-01-19 08:48:55,792 - asyncio - DEBUG - Using selector: KqueueSelector
------------------------------------------ live log call -------------------------------------------
2025-01-19 08:48:55 - asyncio - DEBUG - Using selector: KqueueSelector
2025-01-19 08:48:55,793 - httpx - INFO - HTTP Request: GET http://testserver/weather/Tokyo "HTTP/1.1 200 OK"
2025-01-19 08:48:55 - httpx - INFO - HTTP Request: GET http://testserver/weather/Tokyo "HTTP/1.1 200 OK"
2025-01-19 08:48:55,793 - root - INFO - Received response: {'city': 'Tokyo', 'temperature': 25, 'condition': 'cloudy'}
2025-01-19 08:48:55 - root - INFO - Received response: {'city': 'Tokyo', 'temperature': 25, 'condition': 'cloudy'}
PASSED2025-01-19 08:48:55,794 - asyncio - DEBUG - Using selector: KqueueSelector

---------------------------------------- live log teardown -----------------------------------------
2025-01-19 08:48:55 - asyncio - DEBUG - Using selector: KqueueSelector

tests/test_app.py::test_update_weather_success 2025-01-19 08:48:55,794 - asyncio - DEBUG - Using selector: KqueueSelector

------------------------------------------ live log setup ------------------------------------------
2025-01-19 08:48:55 - asyncio - DEBUG - Using selector: KqueueSelector
2025-01-19 08:48:55,795 - root - INFO - Mocked update_weather with {'city': 'Osaka', 'temperature': 30, 'condition': 'rainy'}
2025-01-19 08:48:55 - root - INFO - Mocked update_weather with {'city': 'Osaka', 'temperature': 30, 'condition': 'rainy'}
2025-01-19 08:48:55,796 - asyncio - DEBUG - Using selector: KqueueSelector
------------------------------------------ live log call -------------------------------------------
2025-01-19 08:48:55 - asyncio - DEBUG - Using selector: KqueueSelector
2025-01-19 08:48:55,797 - httpx - INFO - HTTP Request: PUT http://testserver/weather/Osaka "HTTP/1.1 200 OK"
2025-01-19 08:48:55 - httpx - INFO - HTTP Request: PUT http://testserver/weather/Osaka "HTTP/1.1 200 OK"
2025-01-19 08:48:55,797 - root - INFO - Updated weather: {'city': 'Osaka', 'temperature': 30, 'condition': 'rainy'}
2025-01-19 08:48:55 - root - INFO - Updated weather: {'city': 'Osaka', 'temperature': 30, 'condition': 'rainy'}
PASSED2025-01-19 08:48:55,797 - asyncio - DEBUG - Using selector: KqueueSelector

---------------------------------------- live log teardown -----------------------------------------
2025-01-19 08:48:55 - asyncio - DEBUG - Using selector: KqueueSelector


======================================== 2 passed in 0.27s =========================================
```

# おわりに
今回の調査後cursorに書いてもらったコードを見ると、あるべき姿とは大分異なることが分かりました。ただし、書いて欲しい内容をきちんと伝えるとよしなにコードを修正してくれます。
より堅牢でメンテナンスをしやすいコードを書くには、指示する側の僕たちの知識が重要だと改めて感じました。
今後は振り返り用に少しずつ追記・修正を加えていきます。
皆さんのお役に立てれば幸いです。

# 参考文献
- [Full pytest documentation -pytest](https://docs.pytest.org/en/latest/contents.html#)
- [pytestを利用して効率的に単体テストを書く](https://note.shiftinc.jp/n/n961e4a7833c3)
- [pytest で単体テストの方法まとめ](https://qiita.com/waterada/items/6143d80896eb9d89bf2f)
- [pytest-mockを調査してわかったこと ~モックの最適な使い方~](https://zenn.dev/nyanchu/articles/pytest_mock_8da0886bbb9087)
- [How to use fixtures -pytest](https://docs.pytest.org/en/latest/how-to/fixtures.html)
- [Python の asyncio は超便利](https://qiita.com/waterada/items/1c03a7c863faf9327595)
- [テスト -FastAPI](https://fastapi.tiangolo.com/ja/tutorial/testing/)