はじめに
今年こそはダイエットを心に決めた、ノベルワークスのりょうちん(ryotech34)です。
cursorを使用してpytestでテストを書かせた際に、知らないデコレータなど知識不足が目についたので改めてpytestについてまとめてみました。
対象読者
- pytestにあまり触れたことがない方
- cursorで初めてpytestを使うよっていう方
今回話さないこと
- セットアップと基本的な実行方法
- unittestなどの別ライブラリとの比較
pytestとは
pythonでテストコードを書く際に用いられる外部ライブラリです。
簡単なコードサンプル集は寺田さんの記事に詳しくまとめられていました。
今回は、
- よく使う便利な機能
- cursorに改めて指示しないと別のやり方で実行してしまう機能
をピックアップしました。
fixture
fixtureとは、テストの前処理や後処理など「テストに必要な環境(状態)のセットアップ・クリーンアップ」を簡潔に行うための仕組みです。
よく使用されるケースとして
- ダミーデータの作成
- 一時ファイルの作成と削除
- DBへの接続とクローズ
などが挙げられます。
サンプルコード
今回はUserRepository
というユーザー情報をDBとやり取りするクラスで、ユーザーが存在するか確認を確認するexists
のテストケースです。
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が分割されていることが確認できます。
------------------------------------------ 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
- setup(前処理)
- call(テスト)
- teardown(後処理)
の順番でテストが実行されていることが確認できました。
モック
pytestではunittestも使用でき、モックを作成する際にunittestの方が良いのかpytestのpytest-mock
の方が良いのかで迷いました。
nyanchuさんの記事がとても綺麗にまとめられています。
記事内にMagicMockとpytest-mockerを比較した表がまとめられていました。
おそらくpytest-mock
を使用した方が楽なケースがほとんどだとぼんやり想像しています。
サンプルコード
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
- モックを作成したい関数に
mocker.patch
で指定して置き換える -
return_value
をセットする
ざっくりですが。この流れでmockが作成できることが確認できました。
asyncio
FastAPIやその他機能を実装する際に、async/await
を使用することがよくあると思います。
asyncについてはこちらに詳しくまとめられています。
pytestでは
実際のアプリケーションで非同期で動作しているならば、テストも非同期で実装されていなければなりません。
pytestで非同期処理を実装するにはプラグインツールであるpytest-asyncio
をインストールする必要があります。
pip install pytest-asyncio
コードの違いは以下の通りです。
asyncなし
@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してないよと怒られてる。
py:142: RuntimeWarning: coroutine 'mock_get_weather' was never awaited
asyncあり
デコレータが変化
-
@pytest.fixture
→@pytest_asyncio.fixture
-
@pytest.mark.asyncio
を追加
@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を使ったシステムのテストをしてみます。
テストクライアントについては下記を参照ください。
フォルダ構成
tests/
└── sample/
├── src/
│ ├── app.py
│ └── weatherAPI.py
└── tests/
└── test_app.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
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
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()
テスト実行
cd sample
pytest tests
実行結果
------------------------------------------ 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に書いてもらったコードを見ると、あるべき姿とは大分異なることが分かりました。ただし、書いて欲しい内容をきちんと伝えるとよしなにコードを修正してくれます。
より堅牢でメンテナンスをしやすいコードを書くには、指示する側の僕たちの知識が重要だと改めて感じました。
今後は振り返り用に少しずつ追記・修正を加えていきます。
皆さんのお役に立てれば幸いです。