HypothesisでREST APIのプロパティベーステスト(PBT)をやってみた

# はじめに
FastAPIで作ったREST APIのエンドポイントに対して、PythonのHypothesisを使ってプロパティベーステストを実施する方法を調べながら学んだので本記事に残します。

# 本記事の対象読者
- プロパティベーステストの概要を知りたい方
- Hypothesisでできることを知りたい人
- OpenAPI仕様に沿ったREST APIのプロパティベーステストの流れを知りたい人

## Hypothesisを学習した動機
REST API向けのプロパティベーステストに興味を持ったことがきっかけです。

PythonでREST APIのプロパティベーステストをする場合、有名なものとして**Schemathesis**ライブラリがあることを知りました。

https://github.com/schemathesis/schemathesis

Schemathesisは、APIのエンドポイントに対して多様な入力値を自動生成し、APIがOpenAPI仕様（OAS）に沿った振る舞いをするかどうかをテストできるライブラリです。

SchemathesisはOASを用意すれば、あとはその仕様に沿ってテストデータの生成および実行をしてくれるすごいやつなのですが、そもそもどういう仕組みで動いているのかが気になりました。

SchemathesisのREADMEに記載されている通り、Schemathesisはプロパティベーステスト用のライブラリである**Hypothesis**に基づいて構築されているとのことです。

https://github.com/HypothesisWorks/hypothesis

なので、まずは基となるHypothesisを使ってREST APIのプロパティベーステストをやってみようと思い、色々試してみたというのが今回の背景になります。

# プロパティベーステストとは
https://www.lambdanote.com/collections/proper-erlang-elixir

上記の記事から引用します。

> 従来のユニットテストでは、人間が「入力に対してコードが返すべき値」を考えて、その通りの結果が得られるかどうかをテストします。 
これに対してプロパティベーステストでは、数万にも及ぶ多様なテストケースをコンピューターで自動生成し、その大量のテストを水面下で実行することによって、どんな入力に対してどんな問題が起きるかをテストします。
人間には思いもつかない入力まで網羅できることから、単に手間をかけずにテストケースを増えせるだけでなく、場合によっては仕様に潜むバグさえもあぶり出せる強力なテスト手法です。

ここで、APIエンドポイントに対する「従来のユニットテスト」の例をあげます。
例えばユーザーを作成するPOSTエンドポイントに対してテストを実行する場合、以下のようなテストケースを作ると思います。
```python: 
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()`ストラテジを使います。
```python: 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的な扱いですね）
```python: パイプの実行例
>>> strategy = st.integers() | st.text() | st.booleans()
>>> strategy.example()
'0¯m\x87+<^\x80\U00042d39\x01 j' # テキストが返却された
>>> strategy.example()
0  # 整数が返却された
>>> strategy.example()
52　　# 整数が返却された
```


ストラテジを用いてランダムなデータを生成できることがわかったところで、
先ほどの「従来のユニットテスト」のテストデータを作る場合を考えてみます。↓
```python: 従来のユニットテスト抜粋
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` の３つの項目が含まれた辞書を作成しています。
このような辞書データを作る場合は `fix_dictionaries()` ストラテジを利用できます。
`fix_dictionaries()`の動きとしては、辞書のvalueはストラテジで動的に生成し、keyは指定した値で固定してくれます。
```python: fix_dictionaries()の実行例
>>> 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の両方をストラテジで動的に生成する関数です。
```python: dictionaries()の実行例
>>> 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してください。

https://github.com/K-dash/fastapi-hypothesis-tests


## 動作環境
- python 3.11.9
- 使用ライブラリは以下
```text: requirements.txt
uvicorn==0.29.0
fastapi==0.110.1
pydantic[email]
schemathesis==3.27.1
```
schemathesisをインストールすると、依存関係としてHypothesisや今回利用するjsonschemaなどもインストールしてくれます。
※schemathesis自体は使いません。

## ディレクトリ構成
```bash
.
├── app
│   ├── api
│   │   ├── api.py
│   │   └── schema.py
│   ├── app.py
│   ├── openapi.yaml
│   └── test_hypothesis.py
└── requirements.txt
```

## FastAPIの設定
```python: app.py
from typing import Union
from fastapi import FastAPI

app = FastAPI(debug=True, docs_url="/docs/users")

from api import api
```

## APIエンドポイント
本記事では、FastAPIの以下エンドポイントのプロパティベーステストを行います。
- ユーザー作成（`POST /users`）

```python: api.py
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`です。
```python: schema.py
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` へ送信するリクエストペイロードの正常系データは以下の構造です。
```json: リクエストペイロード
{
  "profile": {
    "name": "test",
    "age": 30,
    "email": "user@example.com"
  }
}
```
`POST /users` から返却される正常系のレスポンスペイロードは以下です。
作成したユーザー情報とは別に、発行したIDと更新日時を含めています。
```json: レスポンスペイロード
{
  "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`）を用意します。
```yaml: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の作成方法に関しては、詳しく説明している記事を載せておきます。

https://zenn.dev/peraichi_blog/articles/01ges56ak79g2rket9wm27w9pc

https://qiita.com/teinen_qiita/items/e440ca7b1b52ec918f1b


## プロパティベーステスト
`POST /users`エンドポイントに対するHypothesisを利用したプロパティベーステストです。
まずは完成形を載せます。
```python: test_hypothesis.py
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
```
各処理単位で細かくコメント書いていますが、やっていることとしては、
1. Hypothesisでテストデータ用のストラテジを定義し、テスト関数に渡す
1. テスト関数は、受け取ったテストデータ（リクエストペイロード）をFastAPIのPOSTエンドポイントに送信
1. リクエストペイロードがOASのスキーマ（`CreateUserSchema`）に適合していることを確認
1. 適合しているか否かによって、レスポンスのステータスコードを評価する
1. リクエストが正しい場合は、レスポンスペイロードもスキーマ（`GetUserSchema`）に適合していることを確認

なお、3、5の処理に関してはコメントだけだと分かりづらいので、以下にポイントを掻い摘んで説明します。

### `is_valid_payload()`について
この関数は、「ペイロード」と「OAS内の検証に使うスキーマ」の２つを受け取り、
テストケースから渡されたペイロードがOAS内に定義されたスキーマ（`CreateUserSchema` と `GetUserSchema`） に適合しているかを検証し結果を返します。

検証処理自体は、`jsonschema.validate()`が行います。
適合していれば、そのペイロードはAPIの仕様に基づいて正しく形成されていることを意味します。

#### リゾルバの使用
`jsonschema.validate()` の `resolve=` 引数に渡している `RefResolver` オブジェクトは、OASスキーマ内の `$ref` キーワードを解決するために使用しています。
```yaml: openapi.yaml
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個生成され、その個数分のテストが実行されます。
```shell
$ 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の使い方に関する記事を書きたいと思います。

# 参考
https://www.amazon.co.jp/%E5%AE%9F%E8%B7%B5%E3%83%9E%E3%82%A4%E3%82%AF%E3%83%AD%E3%82%B5%E3%83%BC%E3%83%93%E3%82%B9API-Jos%C3%A9-Haro-Peralta/dp/4798179736