📝

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

2025/01/19に公開

はじめに

今年こそはダイエットを心に決めた、ノベルワークスのりょうちん(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のテストケースです。

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
  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を使用した方が楽なケースがほとんどだとぼんやり想像しています。

サンプルコード

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をインストールする必要があります。

pip install pytest-asyncio

https://pypi.org/project/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を使ったシステムのテストをしてみます。
テストクライアントについては下記を参照ください。
https://fastapi.tiangolo.com/ja/tutorial/testing/

フォルダ構成

tests/
└── sample/
    ├── src/
    │   ├── app.py
    │   └── weatherAPI.py
    └── tests/
        └── test_app.py

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

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

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

参考文献

Discussion