3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-01-19

はじめに

今年こそはダイエットを心に決めた、ノベルワークスのりょうちん(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
  1. setup(前処理)
  2. call(テスト)
  3. 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
  1. モックを作成したい関数にmocker.patchで指定して置き換える
  2. 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

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

参考文献

3
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?