はじめに
FastAPIで作ったREST APIのエンドポイントに対して、PythonのHypothesisを使ってプロパティベーステストを実施する方法を調べながら学んだので本記事に残します。
本記事の対象読者
- プロパティベーステストの概要を知りたい方
- Hypothesisでできることを知りたい人
- OpenAPI仕様に沿ったREST APIのプロパティベーステストの流れを知りたい人
Hypothesisを学習した動機
REST API向けのプロパティベーステストに興味を持ったことがきっかけです。
PythonでREST APIのプロパティベーステストをする場合、有名なものとしてSchemathesisライブラリがあることを知りました。
Schemathesisは、APIのエンドポイントに対して多様な入力値を自動生成し、APIがOpenAPI仕様(OAS)に沿った振る舞いをするかどうかをテストできるライブラリです。
SchemathesisはOASを用意すれば、あとはその仕様に沿ってテストデータの生成および実行をしてくれるすごいやつなのですが、そもそもどういう仕組みで動いているのかが気になりました。
SchemathesisのREADMEに記載されている通り、Schemathesisはプロパティベーステスト用のライブラリであるHypothesisに基づいて構築されているとのことです。
なので、まずは基となるHypothesisを使ってREST APIのプロパティベーステストをやってみようと思い、色々試してみたというのが今回の背景になります。
プロパティベーステストとは
上記の記事から引用します。
従来のユニットテストでは、人間が「入力に対してコードが返すべき値」を考えて、その通りの結果が得られるかどうかをテストします。
これに対してプロパティベーステストでは、数万にも及ぶ多様なテストケースをコンピューターで自動生成し、その大量のテストを水面下で実行することによって、どんな入力に対してどんな問題が起きるかをテストします。
人間には思いもつかない入力まで網羅できることから、単に手間をかけずにテストケースを増えせるだけでなく、場合によっては仕様に潜むバグさえもあぶり出せる強力なテスト手法です。
ここで、APIエンドポイントに対する「従来のユニットテスト」の例をあげます。
例えばユーザーを作成するPOSTエンドポイントに対してテストを実行する場合、以下のようなテストケースを作ると思います。
from fastapi.testclient import Testclient
from app import app
test_client = TestClient(app=app)
def test_create_user_success():
# 有効なリクエストペイロードを作成
payload = {
"name": "testuser", "age": 2, "email": "a@a.com",
}
response = test_client.post("/users", json=payload)
# レスポンスの検証
assert response.status_code == 201
def test_create_user_fails():
# email項目を削除して無効なリクエストペイロードを作成
payload = {
"name": "testuser", "age": 2,
}
response = test_client.post("/users", json=payload)
# レスポンスの検証
assert response.status_code == 422
このアプローチの問題点としては、網羅的なテストケースを何時間もかけて作成する意気込みがない限り、かなり限られたものになってしまうことです。
上記の例はまったく網羅性がないテストケースであり、一般的にはリクエスト/レスポンスの各ペイロードに対して以下のような確認観点も必要です。
- データ型が合っているか
-
データの制約に準拠しているか
- 想定している範囲内に収まっているか
- 数の最小値や最大値、文字数の長さなど
- nullが与えられたときの挙動はどうか
- 想定している範囲内に収まっているか
-
データの構造は正しいか
- ペイロードに必須の項目が欠けていないか(上記例で確認しているテストケース)
- ペイロードに意図しない項目が含まれていたときの振る舞いはどうか
プロパティベーステストを取り入れることで、ペイロードの種類として考えられるテストデータを自動生成してくれるので、上記のような観点を網羅的に確認できます。
これによって、テストケースの作成にかける時間と労力を大幅に削減することができます。
Hypothesisとは
Hypothesisはプロパティベーステスト用のライブラリであり、テストすべきプロパティ(性質)を満たすような多様な入力データを自動的に生成し、テストを実行できます。
まずはHypothesisを軽く触ってみる
pip install hypothesis
でインストールできます。
Hypothesisはストラテジという概念を使ってテストデータを生成します。
例えば、ランダムな整数を作る場合はintegers()
ストラテジを使います。
>>> from hypothesis import strategies as st
>>> st.integers().example()
26
>>> st.integers().example()
62
>>> st.integers().example()
-5157443141530645704
上記の通り、実行するたびに出力されるデータが変わります。
ストラテジは他にも、ランダムな文字列を生成するtext()
や、True or Falseのbooleans()
、ランダムなemailアドレスを生成するemails()
など、色々あります。
また、パイプ(|
)を使うと様々なストラテジを組み合わせることができます。
これにより結果として得られる値は、結合されたストラテジのいずれかの値となります。(Union的な扱いですね)
>>> strategy = st.integers() | st.text() | st.booleans()
>>> strategy.example()
'0¯m\x87+<^\x80\U00042d39\x01 j' # テキストが返却された
>>> strategy.example()
0 # 整数が返却された
>>> strategy.example()
52 # 整数が返却された
ストラテジを用いてランダムなデータを生成できることがわかったところで、
先ほどの「従来のユニットテスト」のテストデータを作る場合を考えてみます。↓
def test_create_user_success():
# 有効なリクエストペイロードを作成
payload = {
"name": "testuser", "age": 2, "email": "a@a.com",
}
...省略
def test_create_user_fails():
# email項目を削除して無効なリクエストペイロードを作成
payload = {
"name": "testuser", "age": 2,
}
上記の成功パターンでは、リクエストペイロードに name
, age
, email
の3つの項目が含まれた辞書を作成しています。
このような辞書データを作る場合は fix_dictionaries()
ストラテジを利用できます。
fix_dictionaries()
の動きとしては、辞書のvalueはストラテジで動的に生成し、keyは指定した値で固定してくれます。
>>> strategy = st.fixed_dictionaries(
... {
... "name": st.text() | st.integers(),
... "age": st.text() | st.integers(),
... "email": st.integers() | st.emails(),
... }
... )
# 実行結果
>>> strategy.example()
{'name': '\U000ba6ab61', 'age': '\U000ec3a4\U0001ee78·\x076', 'email': '203@s.TraiNIng'}
>>> strategy.example()
{'name': '\x98¹S.$\U0008f1c3\x05', 'age': '\x98¹S0¹\U0008f1c3\x05', 'email': -273301453}
# ↑keyは変動しないが、valueは実行するたびに変化している
また、失敗パターンとして取り上げた「特定の項目が欠落」しているケースを作りたい場合は、dictionaries()
ストラテジが利用できます。
dictionaries()
は、key/valueの両方をストラテジで動的に生成する関数です。
>>> values_strategy = st.text() | st.integers() | st.emails()
>>> strategy = st.dictionaries(
... keys=st.sampled_from(["name", "age", "email"]),
... values=values_strategy,
... min_size=0, # 最小サイズを0にすることで空の辞書も許容
... max_size=3 # 最大サイズをキーの数と同じに設定
... )
# 実行結果
>>> strategy.example()
{'age': '\x9bf\U000ba8b1óù\x91\x1c', 'name': 'Wowa2@P.G.Vn.HoLidAy'}
>>> strategy.example()
{'age': 0, 'email': '0ÿQ\xa0Ïék\U000e3531áµ', 'name': '6$'}
>>> strategy.example()
{'age': '', 'email': 'L'}
# ↑keyもvalueも実行するたびに変化している
ここまでで、Hypothesisがテストデータを動的に生成する動きがわかったかと思います。
この多様なテストデータを生成する機能を使い、大量のテストケースをぶん回すことで、人間には思いもつかない入力まで網羅できそうですよね。
今までの内容を踏まえ、以降はFastAPIを使用して構築したエンドポイントが、OpenAPI仕様(OAS)で定めた要件に適合しているかをHypothesisを使って検証してみます。
環境情報と作成したコード
手元の環境で手っ取り早く試したいという方は、以下のGithubリポジトリからcloneしてください。
動作環境
- python 3.11.9
- 使用ライブラリは以下
uvicorn==0.29.0
fastapi==0.110.1
pydantic[email]
schemathesis==3.27.1
schemathesisをインストールすると、依存関係としてHypothesisや今回利用するjsonschemaなどもインストールしてくれます。
※schemathesis自体は使いません。
ディレクトリ構成
.
├── app
│ ├── api
│ │ ├── api.py
│ │ └── schema.py
│ ├── app.py
│ ├── openapi.yaml
│ └── test_hypothesis.py
└── requirements.txt
FastAPIの設定
from typing import Union
from fastapi import FastAPI
app = FastAPI(debug=True, docs_url="/docs/users")
from api import api
APIエンドポイント
本記事では、FastAPIの以下エンドポイントのプロパティベーステストを行います。
- ユーザー作成(
POST /users
)
import uuid
from starlette import status
from app import app
from api.schema import (
CreateUserSchema,
GetUsersSchema,
GetUserSchema,
)
USERS = [] # 作成したユーザー保存用のインメモリリスト
@app.get("/users", response_model=GetUsersSchema)
def get_users():
return {"users": USERS}
# 今回テスト対象とするユーザー作成用のAPIエンドポイント
@app.post("/users", status_code=status.HTTP_201_CREATED, response_model=GetUserSchema)
def create_user(request_payload: CreateUserSchema):
user = request_payload.model_dump()
user["id"] = uuid.uuid4()
# 作成したユーザーをリストに追加
USERS.append(user)
return user
※ユーザ一の一覧取得用のエンドポイント( get_users()
)もありますが、今回こちらのテストは行いません。
データ検証モデル
POSTリクエスト時のペイロード用の検証モデルが CreateUserSchema
で、
レスポンスペイロード用の検証モデルが GetUserSchema
です。
from datetime import datetime
from zoneinfo import ZoneInfo
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, EmailStr, conint, field_validator
from typing import ClassVar, Dict
class UserProfileSchema(BaseModel):
name: str
age: Optional[conint(ge=0, le=120, strict=True)] # type: ignore
email: EmailStr
@field_validator("age")
def age_non_nullable(cls, value):
"""ageはOptional項目だが、null は許容しない"""
assert value is not None, "age may not be None"
return value
Config: ClassVar[Dict[str, any]] = {
"extra": "forbid", # UserProfileSchemaに定義していない項目は受け付けない
}
class CreateUserSchema(BaseModel):
profile: UserProfileSchema
Config: ClassVar[Dict[str, any]] = {
"extra": "forbid", # CreateUserSchemaに定義していない項目は受け付けない
}
class GetUserSchema(CreateUserSchema):
id: UUID
lastupdated: datetime = datetime.now(ZoneInfo("Asia/Tokyo"))
class GetUsersSchema(BaseModel):
users: List[GetUserSchema]
POST /users
へ送信するリクエストペイロードの正常系データは以下の構造です。
{
"profile": {
"name": "test",
"age": 30,
"email": "user@example.com"
}
}
POST /users
から返却される正常系のレスポンスペイロードは以下です。
作成したユーザー情報とは別に、発行したIDと更新日時を含めています。
{
"profile": {
"name": "test",
"age": 30,
"email": "user@example.com"
},
"id": "d40bdbde-5424-4718-9d2c-27768c8ed8e2",
"lastupdated": "2024-05-02T00:59:09.919276+09:00"
}
OpenAPI仕様
今回のAPIの要件(api.py
とschema.py
の内容)を反映したOASファイル(openapi.yaml
)を用意します。
openapi: 3.1.0
info:
title: FastAPI
version: 0.1.0
paths:
"/users":
get:
summary: Get Users
operationId: get_users_users_get
responses:
'200':
description: Successful Response
content:
application/json:
schema:
"$ref": "#/components/schemas/GetUsersSchema"
post:
summary: Create User
operationId: create_user_users_post
requestBody:
content:
application/json:
schema:
"$ref": "#/components/schemas/CreateUserSchema"
required: true
responses:
'201':
description: Successful Response
content:
application/json:
schema:
"$ref": "#/components/schemas/GetUserSchema"
'422':
description: Validation Error
content:
application/json:
schema:
"$ref": "#/components/schemas/HTTPValidationError"
components:
schemas:
CreateUserSchema:
properties:
profile:
"$ref": "#/components/schemas/UserProfileSchema"
additionalProperties: false
type: object
required:
- profile
title: CreateUserSchema
GetUserSchema:
properties:
profile:
"$ref": "#/components/schemas/UserProfileSchema"
id:
type: string
format: uuid
title: Id
lastupdated:
type: string
format: date-time
title: Lastupdated
default: '2024-05-01T18:08:52.151287+09:00'
additionalProperties: false
type: object
required:
- profile
- id
title: GetUserSchema
GetUsersSchema:
properties:
users:
items:
"$ref": "#/components/schemas/GetUserSchema"
type: array
title: Users
type: object
required:
- users
title: GetUsersSchema
HTTPValidationError:
properties:
detail:
items:
"$ref": "#/components/schemas/ValidationError"
type: array
title: Detail
type: object
title: HTTPValidationError
UserProfileSchema:
properties:
name:
type: string
title: Name
minLength: 1
age:
anyOf:
- type: integer
maximum: 120
minimum: 0
title: Age
email:
type: string
format: email
title: Email
pattern: "^\\S+@\\S+\\.\\S+$"
additionalProperties: false
type: object
required:
- name
- age
- email
title: UserProfileSchema
ValidationError:
properties:
loc:
items:
anyOf:
- type: string
- type: integer
type: array
title: Location
msg:
type: string
title: Message
type:
type: string
title: Error Type
type: object
required:
- loc
- msg
- type
title: ValidationError
OASの作成方法に関しては、詳しく説明している記事を載せておきます。
プロパティベーステスト
POST /users
エンドポイントに対するHypothesisを利用したプロパティベーステストです。
まずは完成形を載せます。
from pathlib import Path
import hypothesis.strategies as st
import jsonschema
import yaml
from fastapi.testclient import TestClient
from hypothesis import given, Verbosity, settings
from jsonschema import ValidationError, RefResolver
from app import app
# OpenAPI仕様(openapi.yaml)を読み込む
users_api_spec = yaml.full_load(
(Path(__file__).parent / "openapi.yaml").read_text()
)
# OpenAPI仕様の中で、POSTリクエスト/レスポンスの要件が記述されているポインタをそれぞれ取得
# リクエストペイロード用
create_user_schema = users_api_spec["components"]["schemas"]["CreateUserSchema"]
# レスポンスペイロード用
get_user_schema = users_api_spec["components"]["schemas"]["GetUserSchema"]
def is_valid_payload(payload, schema):
"""引数に渡されたペイロードがOpenAPI仕様に準拠しているかどうか検証するための関数"""
try:
# 検証はjsonschemaのvalidate関数を使う
jsonschema.validate(
payload, schema=schema,
resolver=RefResolver("", users_api_spec)
)
except ValidationError as e:
print(f"Validation error: {e.message}")
return False
else:
print(f"Validation successful: {payload}")
return True
test_client = TestClient(app=app)
# リクエストペイロードがとり得るストラテジをパイプで定義
values_strategy = (
st.none() |
st.text() |
st.integers() |
st.emails()
)
# テスト用のペイロードとしてストラテジを3つ作成する
# 1. keyは固定でvalueは動的に生成するストラテジ
random_value_strategy = st.fixed_dictionaries(
{
"name": values_strategy,
"age": values_strategy,
"email": values_strategy
}
)
# 2. keyとvalueの両方を動的に生成するストラテジ
random_key_value_strategy = st.dictionaries(
# 無効な項目として、 invalid_field 項目を含める
keys=st.sampled_from(["name", "age", "email", "invalid_field"]),
values=values_strategy,
min_size=0,
max_size=4
)
# 3. 期待値を含めたストラテジ
expected_key_value_strategy = st.fixed_dictionaries(
{
"name": st.text(min_size=1),
"age": st.integers(min_value=0, max_value=120),
"email": st.emails()
}
)
# 1,2,3を結合したストラテジを定義
strategy = st.fixed_dictionaries({"profile": random_value_strategy}) | \
st.fixed_dictionaries({"profile": random_key_value_strategy}) | \
st.fixed_dictionaries({"profile": expected_key_value_strategy})
# Hypothesisを用いたPOSTエンドポイントに対するプロパティベーステスト関数
# settignsでテスト関数の実行回数を指定
@settings(verbosity=Verbosity.verbose, max_examples=1000)
# @given デコレータをテスト関数に適用し、テスト関数が受け取るべき入力データ(ストラテジ)を注入する
@given(strategy)
def test_post(request_payload): # request_payload引数にはストラテジが生成した値が渡される
# POST /usersエンドポイントへテスト実行
response = test_client.post("/users", json=request_payload)
# リクエストペイロードがOpenAPI仕様に定めた要件に適合しているかどうかを判断
if is_valid_payload(request_payload, create_user_schema):
assert response.status_code == 201
# リクエストが正しい場合、レスポンスペイロードがOpenAPI仕様に定めた要件に適合しているかどうかを判断
assert is_valid_payload(response.json(), get_user_schema)
else:
assert response.status_code == 422
各処理単位で細かくコメント書いていますが、やっていることとしては、
- Hypothesisでテストデータ用のストラテジを定義し、テスト関数に渡す
- テスト関数は、受け取ったテストデータ(リクエストペイロード)をFastAPIのPOSTエンドポイントに送信
- リクエストペイロードがOASのスキーマ(
CreateUserSchema
)に適合していることを確認 - 適合しているか否かによって、レスポンスのステータスコードを評価する
- リクエストが正しい場合は、レスポンスペイロードもスキーマ(
GetUserSchema
)に適合していることを確認
なお、3、5の処理に関してはコメントだけだと分かりづらいので、以下にポイントを掻い摘んで説明します。
is_valid_payload()
について
この関数は、「ペイロード」と「OAS内の検証に使うスキーマ」の2つを受け取り、
テストケースから渡されたペイロードがOAS内に定義されたスキーマ(CreateUserSchema
と GetUserSchema
) に適合しているかを検証し結果を返します。
検証処理自体は、jsonschema.validate()
が行います。
適合していれば、そのペイロードはAPIの仕様に基づいて正しく形成されていることを意味します。
リゾルバの使用
jsonschema.validate()
の resolve=
引数に渡している RefResolver
オブジェクトは、OASスキーマ内の $ref
キーワードを解決するために使用しています。
components:
schemas:
CreateUserSchema:
properties:
profile:
"$ref": "#/components/schemas/UserProfileSchema" # これ
additionalProperties: false
type: object
required:
- profile
title: CreateUserSchema
GetUserSchema:
properties:
profile:
"$ref": "#/components/schemas/UserProfileSchema" # これ
# ...省略
UserProfileSchema: # 参照先
properties:
name:
type: string
title: Name
minLength: 1
age:
anyOf:
- type: integer
maximum: 120
minimum: 0
title: Age
email:
type: string
format: email
title: Email
pattern: "^\\S+@\\S+\\.\\S+$"
RefResolver
を使うことで、スキーマが他のスキーマを参照している場合に参照先を適切に解決し、OASスキーマ全体のバリデーションを正確に行うことができます。
プロパティベーステストを実行してみる
以下の通り、pytestコマンドでtest_hypothesis.py
を実行します。
test_hypothesis.py
のmax_examples
引数で指定した通り、テストデータは1000個生成され、その個数分のテストが実行されます。
$ pytest app/test_hypothesis.py -s
================================================================================================== test session starts ===================================================================================================
platform linux -- Python 3.11.9, pytest-8.2.0, pluggy-1.5.0
rootdir: /workspace
plugins: hypothesis-6.100.2, anyio-4.3.0, subtests-0.7.0, schemathesis-3.27.1
collected 1 item
Validation error: 'name' is a required property
Trying example: test_post(
request_payload={'profile': {'name': '0', 'age': 0, 'email': '0@A.com'}},
)
app/test_hypothesis.py Trying example: test_post(
request_payload={'profile': {'name': None, 'age': None, 'email': None}},
)
Validation error: None is not of type 'string'
Trying example: test_post(
request_payload={'profile': {}},
)
Validation successful: {'profile': {'name': '\U00058aaaì', 'age': 27, 'email': 'n_@A.NiSSAN'}}
Validation successful: {'profile': {'name': '\U00058aaaì', 'age': 27, 'email': 'n_@a.nissan'}, 'id': '971c2d11-f435-4e0e-8f12-539310145e57', 'lastupdated': '2024-05-02T07:59:42.856899+09:00'}
Trying example: test_post(
request_payload={'profile': {'age': None}},
)
Validation error: Additional properties are not allowed ('invalid_field' was unexpected)
Trying example: test_post(
request_payload={'profile': {'age': 2, 'name': ''}},
)
Validation error: 'email' is a required property
Trying example: test_post(
request_payload={'profile': {'age': 'ԁ',
'name': None,
'invalid_field': -108}},
)
...省略
.
========================== 1 passed in 7.19s ==============================
pytestのコマンドオプションに-s
をつけると、上記のように生成されたテストデータとprintの内容を確認することができます。
さいごに
以上、Hypothesisを利用したREST APIのプロパティベーステストの簡単な紹介でした。
なお、今回はPOSTエンドポイントのみの簡易的なテストを実行しましたが、
例えば、POSTで作成したUserのIDを使ってGET/PUT/DELETEエンドポイントの試験を行うためには、もう少し複雑なステートフルなテストケースを作る必要があります。
そのようなテストはSchemathesisで簡単に実現できるので、
次は本命であるSchemathesisの使い方に関する記事を書きたいと思います。
参考