🧪

FastAPIとSchemathesisを用いてプロパティ・ベースド・テスト(PBT)を実践してみよう

2023/03/26に公開

前回の投稿では、Schemathesisを用いてOpenAPIのプロパティ・ベースド・テスト(PBT)を行いましたが、テストだけだったため、実際に何が行われているのかが理解しにくかったかもしれません。そこで、今回はPythonのFastAPIモジュールを使ったサーバ側の実装を示し、テストエラーの原因と対策を通じて、プロパティ・ベースド・テストの有用性について説明します。

FastAPIによる実装概要

前回の記事で紹介したOpenAPIのGUIの図に基づいて、

FastAPI example

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