HypothesisでPydanticのPBTをしてみた
はじめに
データ事業本部の藤川です。
PythonにおけるProperty-Based Testing
(PBT; プロパティベーステスト)の代表的なライブラリであるHypothesis
を用いて、プロパティベーステストの実践編をご紹介します。TDD
(Test-Driven Development)やPBT
を導入し、スプリントの完了時に品質まで確保できるよう、シフトレフト
を意識した開発を体験してみましょう。
概要
仕様の提示者から次のお題をいただいたとします。今回は紙面の都合で、1つ目のお題に限定し、パスワードの仕様のみを対象にします。また、TDDに沿って進め、仕様の提示者に確認し、テストをベースに仕様のヌケモレを見出す様子を再現したいと思います。
では、お題を読んで、仕様に関する質問をしてください。
-
お題(引用: 概説テスト分析#25スライド)
例えば次の仕様に対してテスト条件を考えてみる 1. パスワードは4文字以上12文字以下の英数字のみを許容する。 2. パスワードを3分以内に連続して4回以上間違って入力すると、アカウントを5分間ロックする。
PBTはさまざまな入力を自動生成し、想定外のケースを発見する手法です。また、HypothesisはPBTを支援するテストフレームワークです。Pythonに特化したものではありませんが、今回はPython(Pytest)のコードをご紹介します。詳細は、前回の記事をご覧ください。
準備
FastAPIを構築
簡単に動かせるよう、FastAPIアプリで試してみたいと思います。FastAPIのコアであるPydantic
は、リクエストやレスポンスのデータバリデーション、シリアライズ、スキーマ生成に使用される重要なライブラリです。FastAPIはPydanticを活用して、型安全なデータ処理を実現します。Pydanticのモデルに対してPBTを適用することで、APIの品質を確保できると考えます。
-
Pythonのランタイム環境をセットアップします。
python -m venv .venv source .venv/bin/activate mkdir test-hypothesis && cd test-hypothesis poetry init poetry add fastapi "fastapi[standard]" hypothesis
ログイン処理を実装
FastAPIのチュートリアルにあるForm Dataのコードを使用したいと思います。
-
./apps/main.py
ファイルを作成します。mkdir -p ./apps touch ./apps/main.py
-
./apps/main.py
ファイルを編集し、ログイン処理を実装します。from typing import Annotated from fastapi import FastAPI, Form from pydantic import BaseModel, Field, SecretStr app = FastAPI() class FormData(BaseModel): username: str = Field(..., description="ユーザー名") password: SecretStr = Field(..., min_length=4, max_length=12, description="パスワード") @app.post("/login/") async def login(data: Annotated[FormData, Form()]): return data
-
Live Serverを起動します。
fastapi dev ./apps/main.py
-
画面はないため、
http://127.0.0.1:8000/docs
にアクセスし、OpenAPI仕様書を表示します。 -
Try it out
ボタンをクリックし、username
、password
に適当な文字列を入力し、レスポンスを確認しましょう。
テストフレームワークを導入
Testing Frameworkとして、Pytest
を導入します。カバレッジを観測しながら作業できることが望ましいので、pytest-cov
もインストールしておきます。
-
ライブラリを追加します。
poetry add pytest pytest-cov
-
__init__.py
ファイルを作成しておきます。touch ./apps/__init__.py touch ./tests/__init__.py
-
./tests/test_main.py
ファイルを作成します。mkdir -p ./tests touch ./tests/test_main.py
-
./tests/test_main.py
ファイルに次のテストコードを記述します。これが、EBT(Example-Based Testing; 例示ベースのテスト)
によるテストコードです。from apps.main import FormData def test_有効なデータでFormDataインスタンスを作成(): data = FormData(username="testuser", password="secure123") assert data.username == "testuser" assert data.password.get_secret_value() == "secure123" def test_パスワードがちょうど4文字の場合(): data = FormData(username="testuser", password="abcd") # パスワードが4文字 assert data.username == "testuser" assert data.password.get_secret_value() == "abcd" def test_パスワードがちょうど12文字の場合(): data = FormData(username="testuser", password="a" * 12) # パスワードが12文字 assert data.username == "testuser" assert data.password.get_secret_value() == "a" * 12
-
テストを実行できることを確認しておきます。引数なしの
pytest
コマンドでも構いませんが、カバレッジを観測しながら作業することをお勧めします。pytest --cov=. --cov-report=xml -s
やってみた
テストはフェーズではない、アクティビティ(活動)だ
仕様が提示されれば、テストが始まります。テスト条件を意識し、仕様を確認します。コードを書かなくても、テストできます。これらの作業を並行して行います。
- 仕様に関する不明点を質問
- 実装
- テストコードを実装
TDD
まだ、関数定義くらいしか実装していないコードですから、テストコードを書いて、テストを実行するとエラーになるはずです。しかしながら、本当にエラーになるでしょうか?Syntaxエラーくらいしか出ないのではないでしょうか?
TDDの教科書には次の順序で開発を進めるように書いてあります。(敢えて書きますが、TDDは開発手法です。テスト手法ではありません)
- Red(失敗するテストを書く)
- Green(テストを通す実装を行う)
- Refactor(コードを改善する)
実は、ここがポイントだと思っています。絶対にエラーにならない正常系のテストケースしか与えていないため、テストがエラーにならないのではないでしょうか?
では、どのように失敗するテストコードを掛けば良いのでしょうか?仕様に関わらず、広い範囲の条件のテストケースを与えれば、そのいくつかは失敗するケースになるのではないでしょうか。
Hypothesis
Hypothesisを使用します。Hypothesisは入力値のデータ型に合ったテストデータをランダムに生成してくれます。
-
Hypothesis
を追加します。poetry add hypothesis
-
./tests/test_main.py
ファイルを編集し、Hypothesisでテストデータを生成するようにします。from apps.main import FormData from hypothesis import given from hypothesis import strategies as st @given( password=st.text(min_size=4, max_size=12) ) def test_有効なデータでFormDataインスタンスを作成(password): data = FormData(username="testuser", password=password) assert data.username == "testuser" assert data.password.get_secret_value() == password
-
テストを実行します。
pytest --cov=. --cov-report=xml -s
test_有効なデータでFormDataインスタンスを作成()
にHypothesisが生成したpassword
を渡しています。@given
アノテーションを書くだけで、長さ4~12文字のランダムな文字列を生成します。test_パスワードがちょうど4文字の場合()
、test_パスワードがちょうど12文字の場合()
が不要になります。
また、前回の記事で説明した通り、PBT
はEBT
を補完するものであり、置き換えるものではありません。これらを残しておいて構いません。アノテーションで済むので、見通しの良いテストコードになったと思いませんか?これだと、テストコードをレビューし易いです。
テストの話しをする
それだけではありません。print文を補って、Hypothesisが生成するテストデータを見てみましょう。
-
test_有効なデータでFormDataインスタンスを作成()
に次の1行を補い、テスト実行します。print(f"password: {data.password.get_secret_value()}")
-
文字化けかと思うようなテストデータが与えられていることが分かります。
password: Ovø«q1 password: kÌ𢸠< password: ) password: ±Ja|ÆÄܼô password: CKÙIóm;
ここで、お気づきかもしれませんが、Hypothesisのtext()
ストラテジーはオプションを指定していない場合に、Unicode文字を含む文字列を生成します。ASCIIコードに限定されていません。
テストが成功していますから、『パスワードはASCIIコード以外の文字も使える』ことになります。これで良いのでしょうか?与えられたお題に書かれていないことがあれば、確認が必要です。
もしかすると、仕様の提示者は『パスワードはASCIIコード以外の文字のみ使える』と考えているかもしれません。あるいは、私と同様に、エンドユーザーがパスワードにASCIIコード以外のUnicode文字を入力するとは思っていなかったかもしれません。
テストコードから得られた気づきを早い段階で仕様の提示者にフィードバックすることで、仕様の検討不足を補うことができます。
次に、もし、『パスワードはASCIIコード以外の文字のみ使える』とするならば、エンドユーザーがパスワードにASCIIコード以外の文字を入力した場合はエラーにするでしょう。エラーの場合、どんなエラーメッセージを表示するかを定義し、その通りになることをテストする必要があります。
他にも確認しておきたい項目
また、よくあるのが、0
、''(空文字)
、Null
、None
といった入力データに対する振る舞いです。
-
./tests/test_main.py
ファイルを編集し、st.none()
を追加します。from apps.main import FormData from hypothesis import given from hypothesis import strategies as st @given( password=st.text(min_size=4, max_size=12) \ | st.none() ) def test_有効なデータでFormDataインスタンスを作成(password): data = FormData(username="testuser", password=password) print(f"password: {data.password.get_secret_value()}") assert data.username == "testuser" assert data.password.get_secret_value() == password
-
このコードをテスト実行するとエラーになります。
> data = FormData(username="testuser", password=password) E pydantic_core._pydantic_core.ValidationError: 1 validation error for FormData E password E Input should be a valid string [type=string_type, input_value=None, input_type=NoneType] E For further information visit https://errors.pydantic.dev/2.10/v/string_type E Falsifying example: test_有効なデータでFormDataインスタンスを作成( E password=None, E )
たった今、追加したpassword = None
のケースでエラーが発生していることが分かります。こちらも仕様を確認し、正常系とするのか準正常系とするのか検討しましょう。また、テストケースの網羅性が一目で分かるため、コードレビューもしやすくなります。Noneのケースをテストしたのかしていないのかすぐに分かります。
実装とテストが完了
仕様を満たすソースコードが完成しました。ここで注目したいのは、テストコードだけでなく、ソースコードも完成しているという点です。テストは実装する前から始まっていて、ソースコードが完成するまで続きます。そして、ソースコードが完成した
ということはテストも完了している
、もちろん、仕様のヌケモレもなくなっているという事実です。いかがでしょうか?
-
ソースコード
import re from typing import Annotated from fastapi import FastAPI, Form, Header, HTTPException from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator from pydantic_core import PydanticCustomError # 半角英数字 alphabetic_pattern = r"^[a-zA-Z0-9]*$" app = FastAPI() class FormData(BaseModel): username: str = Field(..., description="ユーザー名") password: SecretStr = Field(..., min_length=4, max_length=12, description="パスワード") model_config = ConfigDict(extra="forbid", regex_engine="python-re") @field_validator("password") def validate_password(cls, password: SecretStr) -> SecretStr: # パスワードは4文字以上12文字以下の英数字のみを許容する if not bool(re.match(alphabetic_pattern, password.get_secret_value())): raise PydanticCustomError('custom_alphabet_and_number', 'パスワードは半角英数字のみ使用できます。') return password
-
テストコード
import pytest from hypothesis import given from hypothesis import strategies as st from pydantic import SecretStr, ValidationError from apps.main import FormData # 半角英数字 alphanumeric_chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' @given( password=st.text(alphabet=alphanumeric_chars, min_size=4, max_size=12) ) def test_パスワードは4文字以上12文字以下の英数字のみを許容する(password: SecretStr): fd = FormData(username="", password=password) assert isinstance(fd.password, SecretStr) @given( password=st.none() \ | st.text() ) def test_パスワードは4文字以上12文字以下の英数字のみを許容する_例外(password: SecretStr): # 指定した例外が発生した場合、テスト成功 with pytest.raises((ValidationError)) as exc_info: fd = FormData(username="", password=password) # 発生した例外について、typeとmsgをチェック for error in exc_info.value.errors(): error_type = error.get('type') error_msg = error.get('msg') # print(f"exc_info: type={error_type}, msg={error_msg}") if error_type == 'custom_alphabet_and_number': assert error_msg == 'パスワードは半角英数字のみ使用できます。'
さいごに
PBTを導入すると、EBTに比べてテストコードの見通しが良くなることを実感していただけたでしょうか?各テストケースにおけるテストの目的が明確になるとメンテナンス性も向上します。
開発チームは、プロジェクトやスプリントの早い段階からテストの話しをして、仕様のヌケモレを防ぐことが大切です。テストコードから開発者自身が気づきを得て、仕様の提示者にフィードバックできる機会の増加は大変重要です。そのためにも、開発者自身がテスト手法を身に着け、仕様のヌケモレに気づけるような仕組みづくりも意識して行きましょう。