📝

FastAPIバックエンドのテストを書いてみた

2023/12/12に公開

FastAPIバックエンドのテストを書いてみた

はじめに

この記事はPanda株式会社アドベントカレンダー2023、12日目の記事です。

Panda株式会社は東京大学松尾研究室・香川高専発のスタートアップで、現在はAR技術とAI技術を駆使しシステム開発に取り込んでいます。

筆者について

Panda株式会社でバックエンド担当している黄です。
ソフトウェア開発・サーバー構築・デプロイメントなどをメインになっています。
トリリンガルですけど、最近は友達に母国語スキルの低下を指摘されていました。

この記事の想定読者

  • FastAPIを用いたバックエンドサーバーのリファクタリングに興味を持つ人
  • FastAPIのユニットテストに経験がない人

また、FastAPIの経験がある方を想定しているため、FastAPIの説明は省略しています。

環境について

Pythonでは数多くテスト用のフレームワークがあり、この記事ではpytestを使っています。

ユニットテストについて

ユニットテスト(英語: unit test)とは、ソースコードの個々のユニットが使用に適しているかどうかを決定するために、関連する制御データ、使用手順、操作手順とともにテストする手法である。 (Wikipedia)

簡単に説明すると、ユニットテストは書いていたプログラムがちゃんと想定通りに動いているかを確認する手法です。プログラムを分離し、それぞれのユニット[1]が正しいことを示すことから、アプリケーション全体の厳格性(rigorousness)と明確性(clarity)を保証できます。

FastAPIの事例

昨日の記事では、FastAPIのリファクタリングについて紹介していました。今日はその続きの形で、バックエンドのテストを書く方法を紹介しようと思います。

昨日リファクタリングを終えたバックエンドサーバーは、こういう形になっています。

フレームワークを利用するmainモジュール:

main.py
# (...前略...)
from api import Data

@app.get("/get_data")
def get_data(id: str):
    return Data.get(id)

@app.post("/post_data")
def post_data(data: schemas.Data):
    return Data.post(data)

if __name__ == "__main__":
    uvicorn.rum("main.app")

APIの詳細を定義するDataモジュール:

api/Data.py
# (...前略...)
import data_crud, database, schemas

def get(id: str):
    with database.SessionManager() as db:
        db_data, error = data_crud.read(db, id)
        if error != None:
            raise HTTPException(
                status_code=422,
                detail=error
            )
    return db_data

def post(data: schemas.Data):
    with database.SessionManager() as db:
        db_data, _ = data_crud.read(db, id)
        if db_data != None:
            raise HTTPException(
                status_code=422,
                detail="Record already exist"
            )
        error = data_crud.create(db, data)
        if error != None:
            raise HTTPException(
                status_code=422,
                detail=error
            )
    return {"message": "create success"}

データベースをアクセスするdatabaseモジュール:

database.py
# (...前略...)
parser = configparser.ConfigParser()
parser.read('./config/database.ini')
config = dict(parser['DEFAULT'])

engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(
    autocommit = False,
    autoflush = False,
    bind=engine
)

Base = declarative_base()

@contextmanager
def SessionManager():
    db = SessionLocal()
    try:
        yield db
    except:
        warnings.warn("DB operation failed")
        db.rollback()
        raise
    finally:
        db.close()

前回の記事では省略しましたが、CRUD[2]を行うdata_crudモジュール:

data_crud.py
# (...前略...)
def read(db: Session, id: str):
    try:
	db_data = db.query(models.Data).filter(models.Data.id==id).first()
	if db_data == None:
	    return None, "Record not found"
	else:
	    return db_data, None
    except SQLAlchemyError as error:
        return None, str(error)

def create(db: Session, data: schemas.Data):
    try:
        db_data = models.Data(**data.dict())
	db.add(db_data)
	db.commit()
	db.refresh(db_data)
	return None
    except SQLAlchemyError as error:
        return str(error)

ユニットテストを書いてみた

ユニットとはプログラムを動かせる最小単位のコードであり、ユニットテストも同じく最小単位ごとでテストを書く。大体の場合、他のクラスやモジュールを依存していない底層からテストを書くのがいい。今回の事例では、data_cruddatabaseが該当しています。

test_data_crud.py
import data_crud

def test_read():
    test_id = "..."

    with database.SessionManager() as db:
        actual, error = data_crud.read(db, test_id)
    assert actual == {...}    # 想定した結果を予めに書く
    assert error == None

def test_write():
    test_id = "..."
    test_data = {
        "id": test_id,
	...
    }

    with database.SessionManager() as db:
        error = data_crud.create(db, test_data)
    assert error == None    # create()は正常に動くかを確認

    with database.SessionManager() as db:
        actual, error = data_crud.read(db, test_id)
    assert actual == {...}    # create()の結果は想定通りになるかを確認

テストケース(test_data_crud)中の関数の使い方はアプリケーション(api/Data)での使い方と同じです。これはユニットテストは関数はどう扱うかを示すことも兼ねるためです。そのため、テストコードを見るだけで各関数の入力、出力または想定した使い方を早く把握できます。

これらユニットを一通テストした後、いよいよユニットに依存するコードをテストすることができます。定義上、他のモジュールに依存するコードはユニットではなくなり、統合テスト(Integration test)になります。今回の事例だとapi/Dataに該当します。

test_api_data.py
from api import Data

def test_get():
    test_id = "..."
    try:
        actual = Data.get(test_id)
        assert actual == {...} # get()の結果は想定通りになるかを確認
    except HTTPException as error:
        assert HTTPException == None    # get()は正常に動くかを確認

def test_post():
    test_id = "..."
    test_data =  {
        "id": test_id,
	...
    }
    try:
        actual = Data.post(data)
	assert actual == {...}

	actual = Data.get(test_id)
	assert actual == {...}    # post()の結果は想定通りになるかを確認
    except HTTPException as error:
        assert HTTPException == None    # post()は正常に動くかを確認

統合テストを終わると、最後は機能テスト(Functional test)になります、これはアプリケーションが想定したユースケースに対するテストになります。例えばオンラインショップだと購買機能、SNSだと書き込みなど、各サービスの最表層(ユーザーに見せる部分)になります。この事例だとmainに該当します。

test_main.py
from fastapi.testclient import TestClient
from .main import app

client = TestClient(app)

def test_get_data():
    response = client.get("/get_data", params={'id': '...'})
    assert response.status_code == 400
    assert response.json() == {...}

def test_post_data()
    test_data = {...}
    response = client.post("/post_data", json=test_data)
    assert response.status_code == 400
    assert response.json() == {...}

今回はFastAPIを用いたWebアプリケーションを対象にしているため、FastAPIのレスポンスを中心にしてテストを行います。一般ではFastAPIのクライアントを模擬してテストを行うはずですが、FastAPIは予めテスト用クライアントを備えており非常に便利です。詳細はFastAPIのドキュメントに参照してください。

おわりに

今回は「バックエンドのテスト」というテーマでPanda株式会社 Advent Calendar 2023 12日目を執筆させていただきました。

アジャイル開発では、小まめのリファクタリングがよく行われるため、リファクタリング前後のシステム保守は非常に重要になります。当然毎回実際のインタフェースからテストを行うことができますが、それは時間を掛かりますし、ヒューマンエラー防止の観点から良くないです。そのため、ちゃんとテストを行うことによってコードの仕様が想定通りに保証することができ、コードの可読性も上がることがあります。最初は無駄足だと思っても、開発時間が長く伸びるほど有難さが増えるのがユニットテストです。備えがあれば憂いなし。

いよいよ明日からはSEMICON Japanです、弊社も松尾研のスタートアップブースにて出展しています、AR技術の応用について興味がある人は是非ビックサイトに一回寄ってください。明日はスマホARの動向についての記事になります、是非お楽しみに!

脚注
  1. アプリケーション最小の部品単位 ↩︎

  2. (Create, Read, Update and Delete) データベース管理システム(DBRS)に必要な4つの主な機能 ↩︎

Panda株式会社

Discussion