Hypothesis: 適切なデータの生成

 
0
このエントリーをはてなブックマークに追加
Daichi Takayama
Daichi Takayama (高山 大地)

以下の文章はこちらの記事を翻訳したものです。

https://github.com/HypothesisWorks/hypothesis/blob/master/HypothesisWorks.github.io/_posts/2016-05-11-generating-the-right-data.md

データモデルに合わせたデータを作り出すことは、難しい課題になることがあります。はじめのうちは、文字列や整数のような単純なデータの生成からスタートするものですが、理想としては、最終的にはそのドメインモデルのオブジェクトまで作成できるようになりたいですね。Hypothesisなどのツールは、求めるデータを構築するために様々な機能を提供していますが、選択肢が豊富すぎてどこから手をつけていいかわからなくなることもあります。

以下では、いくつかのポイントを紹介し、それらを実践的に使いこなすための例を挙げていきます。

例として、以下のようなクラスがあると仮定しましょう:

class Project:
    def __init__(self, name, start, end):
        self.name = name
        self.start = start
        self.end = end

    def __repr__(self):
        return "Project {} from {} to {}".format(
            self.name, self.start.isoformat(), self.end.isoformat()
        )

このクラスはプロジェクトの名前、開始日、終了日を属性として持っています。

では、このようなオブジェクトをどのようにして生成するのでしょうか?

問題を分解し、それぞれの部分をHypothesisが提供するツールを駆使して組み立て、プロジェクト生成のための戦略を立てるという方法があります。

最初に、プロジェクトの各属性に必要なデータを生成し、最終的にこれらを統合してプロジェクトを作成する手順について見ていきましょう。

名前

最初のステップとして、プロジェクトの名前を生成する必要があります。これには、Hypothesisのtext戦略を利用します:

>>> from hypothesis.strategies import text
>>> text().example()
''
>>> text().example()
'\nŁ昘迥'

この戦略を少し調整してみましょう。

>>> text(min_size=1).example()
'w\nC'
>>> text(min_size=1).example()
'ሚಃJ»'

次に、この段階では広範囲のユニコード文字を避けることにしましょう(実際には、あなたのシステムが全てのユニコード範囲に対応しているのが理想ですが、ここではあくまで一例としています)。

これを実現するには、テキスト戦略に使用する文字の範囲を指定する必要があります。これには characters 戦略を使って、特定の文字範囲または他の戦略を指定します。ここでは、characters 戦略を用いて、アルファベットの範囲を柔軟に指定する方法を紹介します。

i>>> characters(min_codepoint=1, max_codepoint=1000, exclude_categories=('Cc', 'Cs')).example()
'²'
>>> characters(min_codepoint=1, max_codepoint=1000, exclude_categories=('Cc', 'Cs')).example()
'E'
>>> characters(min_codepoint=1, max_codepoint=1000, exclude_categories=('Cc', 'Cs')).example()
'̺'

min_codepointmax_codepoint パラメータを使って、受け入れ可能なコードポイントの範囲を制限します。0のコードポイント(使用価値が低く、Cライブラリでの問題が多いため)と、1000以上のコードポイントを避けることで、非ASCII文字を含むものの、非常に高い範囲の文字は含まれないようにしています。

blacklist_categories パラメータを使用することで、特定のユニコード文字カテゴリを除外し、生成される文字の範囲を細かく制御することが可能になります。この機能は、Pythonの unicodedata モジュールと組み合わせることで、特に有用です。 unicodedata モジュールを利用すると、任意の文字のユニコードカテゴリを簡単に調べることができます:

>>> from unicodedata import category
>>> category('\n')
'Cc'
>>> category('\t')
'Cc'
>>> category(' ')
'Zs'
>>> category('a')
'Ll'

ここで、Cc カテゴリは制御文字を、Cs カテゴリはサロゲートペアを表します。これらのカテゴリを明示的に除外することで、テストデータとして生成される文字列の品質を向上させることができます。サロゲートペアはデフォルトで除外されていることが多いですが、blacklist_categories を使用して明示的に指定することで、意図した通りの文字セットのみが生成されるように細かく制御することが可能です。

このアプローチを利用すると、text 関数と characters 関数を組み合わせて、特定の要件を満たす名前を生成することが可能になります:

>>> names = text(characters(max_codepoint=1000, exclude_categories=('Cc', 'Cs')), min_size=1)

この戦略では名前にスペースが含まれることを許容していますが、名前がスペースで始まるか終わるのは望ましい状況ではありません。実際にHypothesisを使って具体的な例を求めた場合、現在このような状況が許可されていることが確認できます:

>>> find(names, lambda x: x[0] == ' ')
' '

この問題を解決するために、生成された名前からスペースを削除する必要があります。

これを行うためには、戦略のmapメソッドを使用します。これにより、任意の関数と合成して、結果を望む形式に後処理することができます:

>>> names = text(characters(max_codepoint=1000, exclude_categories=('Cc', 'Cs')), min_size=1).map(
...     lambda x: x.strip())

これで、上記の問題が解消されたかどうかを確認しましょう:

>>> find(names, lambda x: x[0] == ' ')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.5/site-packages/hypothesis/core.py", line 648, in find
    runner.run()
  File "/usr/lib/python3.5/site-packages/hypothesis/internal/conjecture/engine.py", line 168, in run
    self._run()
  File "/usr/lib/python3.5/site-packages/hypothesis/internal/conjecture/engine.py", line 262, in _run
    self.test_function(data)
  File "/usr/lib/python3.5/site-packages/hypothesis/internal/conjecture/engine.py", line 68, in test_function
    self._test_function(data)
  File "/usr/lib/python3.5/site-packages/hypothesis/core.py", line 616, in template_condition
    success = condition(result)
  File "<stdin>", line 1, in <lambda>
IndexError: string index out of range

おっと!

当初のテストが成功したのは、min_size パラメータによって生成される文字列が常に空ではなかったためです。しかし、文字列が全て空白である場合、strip 関数の適用により結果として得られる文字列が空になる可能性があります。この問題を解決するためには、戦略の filter 機能を利用して、特定の条件を満たすデータのみを生成するようにすることができます:

>>> names = text(characters(max_codepoint=1000, exclude_categories=('Cc', 'Cs')), min_size=1).map(
...     lambda s: s.strip()).filter(lambda s: len(s) > 0)

そしてチェックを繰り返します:

>>> find(names, lambda x: x[0] == ' ')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.5/site-packages/hypothesis/core.py", line 670, in find
    raise NoSuchExample(get_pretty_function_description(condition))
hypothesis.errors.NoSuchExample: No examples found of condition lambda x: <unknown>

Hypothesisは NoSuchExample を発生させます。これは、つまり、指定された条件を満たす例が存在しないことを意味します。

これは、特に filter 関数を使用する際に重要な指標となります。filter 関数は、特定の条件に合致するデータのみを引き出すために利用されますが、その使用は慎重に行うべきです。条件があまりにも特殊であると、生成プロセスが非常に遅くなったり、テストが有意義な結果を得られなくなる可能性があります。本ケースでは、フィルタリングが適切に機能しており、特に問題となる状況は生じていません。これは、フィルタリングが行われるのが、引き出された文字列が全て空白であるような比較的あり得る状況に限定されているためです。

現在、私たちはプロジェクトの名前を生成するための適切な戦略を持っています。この戦略を利用して、生成された名前が我々の要件を満たしているかを検証しましょう:

from unicodedata import category

from hypothesis import given
from hypothesis.strategies import characters, text

names = (
    text(characters(max_codepoint=1000, exclude_categories=("Cc", "Cs")), min_size=1)
    .map(lambda s: s.strip())
    .filter(lambda s: len(s) > 0)
)

@given(names)
def test_names_match_our_requirements(name):
    assert len(name) > 0
    assert name == name.strip()
    for c in name:
        assert 1 <= ord(c) <= 1000
        assert category(c) not in ("Cc", "Cs")

戦略に基づいてテストを記述することは一般的ではありませんが、新しい概念や方法論を理解する上で非常に役立ちます。

日付と時刻

Hypothesisでは、hypothesis.extra.datetime サブパッケージを通じて日付と時刻の生成が可能です。これにより、pytz を使用できるためですが、基本的な使い方は他のデータ型の生成と変わりません:

>>> from hypothesis.extra.datetime import datetimes
>>> datetimes().example()
datetime.datetime(1642, 1, 23, 2, 34, 28, 148985, tzinfo=<DstTzInfo 'Antarctica/Mawson' zzz0:00:00 STD>)

内部的にUTCを使用し、ユーザーへの表示時にタイムゾーンを変換することが一般的なベストプラクティスです。そのため、日付の生成をUTCに限定することは賢い選択と言えます:

>>> datetimes(timezones=('UTC',)).example()
datetime.datetime(6820, 2, 4, 19, 16, 27, 322062, tzinfo=<UTC>)

加えて、プロジェクトの開始年を現実的な範囲内に設定することで、より実用的なテストデータを生成することが可能です。Hypothesisのデフォルト設定では、幅広い期間をカバーしますが、実際のアプリケーションではより限定された時期が対象となることが多いでしょう:

>>> datetimes(timezones=('UTC',), min_year=2000, max_year=2100).example()
datetime.datetime(2084, 6, 9, 11, 48, 14, 213208, tzinfo=<UTC>)

再び、この動作をチェックするテストをまとめることができます(ここではコードが少ないのであまり役に立ちませんが):

from hypothesis import given
from hypothesis.extra.datetime import datetimes

project_date = datetimes(timezones=("UTC",), min_year=2000, max_year=2100)

@given(project_date)
def test_dates_are_in_the_right_range(date):
    assert 2000 <= date.year <= 2100
    assert date.tzinfo._tzname == "UTC"

全てをまとめる

プロジェクトの全ての要素をどう組み合わせて一つのプロジェクトオブジェクトを生成するかという問いに対して、**builds**関数が鍵となります。

>>> from hypothesis.strategies import builds
>>> projects = builds(Project, name=names, start=project_date, end=project_date)
>>> projects.example()
Project 'd!#ñcJν' from 2091-06-22T06:57:39.050162+00:00 to 2057-06-11T02:41:43.889510+00:00

builds 関数は、提供された戦略から得られた値を用いて、関数やクラスコンストラクタに引数として渡すことで、新しい戦略を生成するために活用されます。このプロセスにより、複数の戦略を組み合わせて、複雑なオブジェクトやエンティティのインスタンスを生成することができます。実際には、どのような呼び出し可能なオブジェクトもこの目的で使用することが可能です。

しかし、このアプローチには限界があります。

>>> find(projects, lambda x: x.start > x.end)
Project '0' from 2000-01-01T00:00:00.000001+00:00 to 2000-01-01T00:00:00+00:00

使用する builds メソッドで生成されたプロジェクトでは、場合によって開始日よりも先に終了日が設定されることがあります。この不一致を解決する一つの方法は、filter メソッドの適用です。

>>> projects = builds(Project, name=names, start=project_date, end=project_date).filter(
...     lambda p: p.start < p.end)
>>> find(projects, lambda x: x.start > x.end)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.5/site-packages/hypothesis/core.py", line 670, in find
    raise NoSuchExample(get_pretty_function_description(condition))
hypothesis.errors.NoSuchExample: No examples found of condition lambda x: <unknown>

この方法は確かに有効ですが、生成されたデータの大部分がフィルタリングによって排除されるため、フィルターの使用を避けた方が良い状況に近づいています。

そこで、私たちが取るべき別のアプローチは、二つの日付を引き出し、それらのうち小さい方をプロジェクトの開始日、大きい方を終了日として選ぶことです。このような依存関係を持つ属性を扱う場合、builds 関数だけでは対応が難しいため、より柔軟な composite デコレータを使用します。

from hypothesis import assume
from hypothesis.strategies import composite

@compositedef projects(draw):
    name = draw(names)
    date1 = draw(project_date)
    date2 = draw(project_date)
    assume(date1 != date2)
    start = min(date1, date2)
    end = max(date1, date2)
    return Project(name, start, end)

composite 関数の核心は、戦略から値を「引き出す」ための特別な引数 draw の提供にあります。この draw 引数を使うことで、必要な数だけ値を生成し、これらを組み合わせて最終的なデータオブジェクトを形成することができます。

また、進行できない状態に自分自身を置いた場合、またはやり直しの方が簡単な場合に現在の呼び出しを破棄するために assume 関数も使用できます。このケースでは、同じデータを二度引き出す場合に assume を使用しました。

>>> projects().example()
Project 'rĂ5ĠǓ#' from 2000-05-14T07:21:12.282521+00:00 to 2026-05-12T13:20:43.225796+00:00
>>> find(projects(), lambda x: x.start > x.end)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python3.5/site-packages/hypothesis/core.py", line 670, in find
    raise NoSuchExample(get_pretty_function_description(condition))
hypothesis.errors.NoSuchExample: No examples found of condition lambda x: <unknown>

ここで注目すべき点は、composite 関数を使用する際には projects() のように呼び出す必要があることです。composite は直接戦略を返すのではなく、戦略を生成する関数を返します。これにより、より複雑なデータ生成パターンを柔軟に扱うことが可能になります。

これで、この部分も正しく取り組んだ最後のテストを一緒に行うことができます:

@given(projects())
def test_projects_end_after_they_started(project):
    assert project.start < project.end

締めくくり

Hypothesisを用いたデータ生成の探求はここで紹介した内容に留まりません。さらに多くの可能性があり、実際に手を動かして試すことで、より深い理解を得ることができます。

Hypothesisに関する公式ドキュメントは非常に充実しており、さらなる学習に大変役立ちます。また、何か問題に直面した時や、さらなる助けが必要な場合には、Hypothesisのコミュニティへの参加を検討してみてください。非常にフレンドリーなコミュニティで、質問や疑問に対して助けを得ることができます。

info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。