FastAPIとSchemathesisを用いてプロパティ・ベースド・テスト(PBT)を実践してみよう
前回の投稿では、Schemathesisを用いてOpenAPIのプロパティ・ベースド・テスト(PBT)を行いましたが、テストだけだったため、実際に何が行われているのかが理解しにくかったかもしれません。そこで、今回はPythonのFastAPIモジュールを使ったサーバ側の実装を示し、テストエラーの原因と対策を通じて、プロパティ・ベースド・テストの有用性について説明します。
FastAPIによる実装概要
前回の記事で紹介したOpenAPIのGUIの図に基づいて、
FastAPIを用いた実装を簡潔に説明します
# post data format
class User(BaseModel):
name: str
@app.post("/")
async def create_name(user: User):
global CONNECTION
cursor = CONNECTION.cursor()
cursor.execute(f'INSERT INTO users(name) values("{user.name}")')
CONNECTION.commit()
cursor.close()
上記のコードでは、Userクラスが入力として受け取り、nameが設定された状態で、create_name関数が呼び出されます。その後、SQL文を用いてデータが保存されます。
Schemathesisの結果と問題点の解析
Schemathesisを用いたテストでは、nameにダブルクォートが含まれている場合にエラーが発生していることがわかりました。
curl -X POST -d '{"name": "~\""}' http://localhost:8000/
これは、以下のようなSQL文が生成されるためです。
INSERT INTO users(name) values("~"")
文字列中にダブルコーテーションが入りこみ構文が壊れ、エラーが発生しています。この問題を解決するためには、通常は適切なエスケープ処理をするのが一般的です。とはいえ、それでは面白くありません。プロバティ・ベースド・テストに合った対策を講じましょう。
テストドリブンでコードの修正
プロパティベーステストは、関数の入力定義との相性が非常に良いと考えられます。Pythonでは、デフォルトのdataclassが利用できますが、FastAPIではPydanticのBaseModelを使用します。
現在、nameはstrと文字列全般を受け付けるように設定しているため、ダブルクォートが含まれることがあります。
class User(BaseModel):
name: str
nameの入力条件を正規表現で変更すると、以下のようになります。
class User(BaseModel):
name: str = Field(..., regex=r'^(?!.*[\"]).*$')
この変更により、プログラム自体がダブルクォートを受け付けなくなり、テスト側は正規表現のルールに従った中で有効なテストを実行します。こうすると、効果的なテストを少ない回数で実施できます。
上記の変更を加えた後、再度テストを行うと、今度はNullターミネート文字でエラーが発生します。
curlで表現すると以下の場合となります。
curl -X POST -d '{"name": "\u0000"}' http://localhost:8000/
再度、ソースをいじり、制限を追加していきましょう
ソースの改善と最終的な実装
何度か繰り返しテストと修正を行うと、最終的に以下のような実装に落ち着きました。
class User(BaseModel):
name: str = Field(..., regex=r'^(?!.*[\"\x00]).*$', min_length=1)
このように、プロパティ・ベースド・テストはプログラムのインターフェースの枝刈をすることでソフトの品質を上げるアプローチが取れます。もちろんこれ以外の利用方法もできますが、お試しにはこの手段が有効ではないでしょうか。
Appendix
さいごに、今回作ったプログラムを貼ります。参考にしてください
import sqlite3
from fastapi import FastAPI
from pydantic import BaseModel,Field
app = FastAPI()
CONNECTION=None
@app.on_event("startup")
async def start():
global CONNECTION
CONNECTION = sqlite3.connect(':memory:')
cursor = CONNECTION.cursor()
cursor.execute("""
CREATE TABLE users (
name TEXT NOT NULL
);
""")
CONNECTION.commit()
cursor.close()
# post data format
class User(BaseModel):
name: str = Field(..., regex=r'^(?!.*[\"\x00]).*$', min_length=1)
@app.post("/")
async def create_name(user: User):
global CONNECTION
cursor = CONNECTION.cursor()
print(user)
cursor.execute(f'INSERT INTO users(name) values("{user.name}")')
CONNECTION.commit()
cursor.close()
Discussion