HypothesisでPydanticのPBTをしてみた

HypothesisでPydanticのPBTをしてみた

Pythonにおけるプロパティベーステストの代表的なライブラリである`Hypothesis`を用いた開発プロセスの実践編をご紹介します。
Clock Icon2025.01.29

はじめに

データ事業本部の藤川です。
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の品質を確保できると考えます。

  1. 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のコードを使用したいと思います。

  1. ./apps/main.pyファイルを作成します。

    mkdir -p ./apps
    touch ./apps/main.py
    
  2. ./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
    
  3. Live Serverを起動します。

    fastapi dev ./apps/main.py
    
  4. 画面はないため、http://127.0.0.1:8000/docsにアクセスし、OpenAPI仕様書を表示します。

  5. Try it outボタンをクリックし、usernamepasswordに適当な文字列を入力し、レスポンスを確認しましょう。

テストフレームワークを導入

Testing Frameworkとして、Pytestを導入します。カバレッジを観測しながら作業できることが望ましいので、pytest-covもインストールしておきます。

  1. ライブラリを追加します。

    poetry add pytest pytest-cov
    
  2. __init__.pyファイルを作成しておきます。

    touch ./apps/__init__.py
    touch ./tests/__init__.py
    
  3. ./tests/test_main.pyファイルを作成します。

    mkdir -p ./tests
    touch ./tests/test_main.py
    
  4. ./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
    
  5. テストを実行できることを確認しておきます。引数なしのpytestコマンドでも構いませんが、カバレッジを観測しながら作業することをお勧めします。

    pytest --cov=. --cov-report=xml -s
    

やってみた

テストはフェーズではない、アクティビティ(活動)だ

仕様が提示されれば、テストが始まります。テスト条件を意識し、仕様を確認します。コードを書かなくても、テストできます。これらの作業を並行して行います。

  • 仕様に関する不明点を質問
  • 実装
  • テストコードを実装

TDD

まだ、関数定義くらいしか実装していないコードですから、テストコードを書いて、テストを実行するとエラーになるはずです。しかしながら、本当にエラーになるでしょうか?Syntaxエラーくらいしか出ないのではないでしょうか?

TDDの教科書には次の順序で開発を進めるように書いてあります。(敢えて書きますが、TDDは開発手法です。テスト手法ではありません)

  1. Red(失敗するテストを書く)
  2. Green(テストを通す実装を行う)
  3. Refactor(コードを改善する)

実は、ここがポイントだと思っています。絶対にエラーにならない正常系のテストケースしか与えていないため、テストがエラーにならないのではないでしょうか?

では、どのように失敗するテストコードを掛けば良いのでしょうか?仕様に関わらず、広い範囲の条件のテストケースを与えれば、そのいくつかは失敗するケースになるのではないでしょうか。

Hypothesis

Hypothesisを使用します。Hypothesisは入力値のデータ型に合ったテストデータをランダムに生成してくれます。

  1. Hypothesisを追加します。

    poetry add hypothesis
    
  2. ./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
    
  3. テストを実行します。

    pytest --cov=. --cov-report=xml -s
    

test_有効なデータでFormDataインスタンスを作成()にHypothesisが生成したpasswordを渡しています。@givenアノテーションを書くだけで、長さ4~12文字のランダムな文字列を生成します。test_パスワードがちょうど4文字の場合()test_パスワードがちょうど12文字の場合()が不要になります。

また、前回の記事で説明した通り、PBTEBTを補完するものであり、置き換えるものではありません。これらを残しておいて構いません。アノテーションで済むので、見通しの良いテストコードになったと思いませんか?これだと、テストコードをレビューし易いです。

テストの話しをする

それだけではありません。print文を補って、Hypothesisが生成するテストデータを見てみましょう。

  1. test_有効なデータでFormDataインスタンスを作成()に次の1行を補い、テスト実行します。

        print(f"password: {data.password.get_secret_value()}")
    
  2. 文字化けかと思うようなテストデータが与えられていることが分かります。

    password: Ovø«q򏎮󉸐󔆞1
    password: kÌ𢸠<򱱘
    password: )񍩝𔌪
    password: ±Ja|ÆÄܼô
    password: CKÙIóm;
    

ここで、お気づきかもしれませんが、Hypothesisのtext()ストラテジーはオプションを指定していない場合に、Unicode文字を含む文字列を生成します。ASCIIコードに限定されていません。

テストが成功していますから、『パスワードはASCIIコード以外の文字も使える』ことになります。これで良いのでしょうか?与えられたお題に書かれていないことがあれば、確認が必要です。

もしかすると、仕様の提示者は『パスワードはASCIIコード以外の文字のみ使える』と考えているかもしれません。あるいは、私と同様に、エンドユーザーがパスワードにASCIIコード以外のUnicode文字を入力するとは思っていなかったかもしれません。

テストコードから得られた気づきを早い段階で仕様の提示者にフィードバックすることで、仕様の検討不足を補うことができます。

次に、もし、『パスワードはASCIIコード以外の文字のみ使える』とするならば、エンドユーザーがパスワードにASCIIコード以外の文字を入力した場合はエラーにするでしょう。エラーの場合、どんなエラーメッセージを表示するかを定義し、その通りになることをテストする必要があります。

他にも確認しておきたい項目

また、よくあるのが、0''(空文字)NullNoneといった入力データに対する振る舞いです。

  1. ./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
    
  2. このコードをテスト実行するとエラーになります。

    >       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に比べてテストコードの見通しが良くなることを実感していただけたでしょうか?各テストケースにおけるテストの目的が明確になるとメンテナンス性も向上します。

開発チームは、プロジェクトやスプリントの早い段階からテストの話しをして、仕様のヌケモレを防ぐことが大切です。テストコードから開発者自身が気づきを得て、仕様の提示者にフィードバックできる機会の増加は大変重要です。そのためにも、開発者自身がテスト手法を身に着け、仕様のヌケモレに気づけるような仕組みづくりも意識して行きましょう。

参考

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.