Hypothesisの使い方

この記事ではPython Hypothesisを紹介します。

HypothesisはPythonでProperty-based Testingを行うためのライブラリです。

Property-based Testingとは、ある範囲でランダムに生成された多種多様な入力値に対して、テスト対象が一定の特性を常に満たすかどうかをテストする手法です。ごく簡単な例としては次のようなテストです。

テスト対象入力された整数値に0を掛ける関数
満たして欲しい特性テスト対象の出力値が常に0
テスト内容ランダムな整数値を関数に入力し、出力を0と比べる、という処理を繰り返す。
Property-based Testingの例

一般的なテスト(Example-based Testing)では一つ一つのテストケースを手動実装するのに対し、Property-based Testingでは入力の範囲とテスト対象が満たすべき特性を包括的に記述します。その範囲でテスト対象への入力値をランダムに自動生成し、常に満たすべき特性を確認する、という処理を何度も繰り返すことで、より効率的かつ強力にバグを検出できる場合があります。また、テストによって仕様を記述するビヘイビア駆動開発の観点でも、個別のテストケースを書き並べるよりも見通しが良くなる場合があります。

このようなテストはもちろんライブラリの助けを借りずに実装することもできますが、Hypothesisは、入力値の生成やテスト結果の表示を効率よく実装するための機能を提供します。例えば入力値の生成に対しては、ランダムな整数値を生成する機能やランダムな文字列を生成する機能が事前に用意されています。また、文字列の生成時に空文字やマルチバイト文字を含む文字列を生成したり、浮動小数点数の生成時にNaNやInfを含めたり、エッジケースを自動的に含めてくれます。

目次

インストール

HypothesisはPyPIからpipでインストールできます。

$ pip install hypothesis 

なお、Hypothesisはテストを書くためのライブラリで、テストの実行には別のライブラリを利用する必要があります。この記事ではpytestを利用するやり方を紹介します。pytestの他にはPython標準のunittestなどが利用できます。pytestもpipでインストールできます。

$ pip install pytest

基本的な使い方

テストの入力値と検証内容の書き方

まずは冒頭で紹介した、入力された整数値に0を掛ける関数のテストを実際に実装してみました。

Hypothesisを使用するためには、テストケースとなる関数に@givenアノテーションを付与します。入力値の範囲・条件・パターンの記述にはhypothesis.strategiesモジュール内の関数を利用します。テストの実行にpytestを利用する場合は、テストケースは通常の関数として実装します。

from hypothesis import given
from hypothesis.strategies import integers

# テスト対象
def multiply_zero(num: int):
    return num * 0

# テスト内容
@given(integers())
def test_always_zero(s):
    assert multiply_zero(s) == 0

実行結果は以下の通りです。

$ pytest sample1.py 
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-7.1.2, pluggy-1.0.0
rootdir: ...
plugins: hypothesis-6.47.4
collected 1 item

sample1.py .                                                             [100%]

============================== 1 passed in 0.21s ===============================

問題なくテストを通過しました。ただ、これだと本当に様々な入力値でテストされているのかわかりませんね。

pytestコマンドに対して--hypothesis-show-statisticsオプションを利用すると詳細なテスト内容が確認できます。

$ pytest --hypothesis-show-statistics sample1.py 
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-7.1.2, pluggy-1.0.0
rootdir: /Users/ti/Documents/projects/kangaeru_chikara/articles/article29
plugins: hypothesis-6.47.4
collected 1 item                                                               

sample1.py .                                                             [100%]
============================ Hypothesis Statistics =============================

sample1.py::test_always_zero:

  - during reuse phase (0.00 seconds):
    - Typical runtimes: < 1ms, ~ 44% in data generation
    - 1 passing examples, 0 failing examples, 0 invalid examples

  - during generate phase (0.07 seconds):
    - Typical runtimes: < 1ms, ~ 48% in data generation
    - 99 passing examples, 0 failing examples, 0 invalid examples

  - Stopped because settings.max_examples=100


============================== 1 passed in 0.09s ===============================

デフォルトの100個のテストケースが実行されたことが確認できます。

テストが失敗する例と、入力範囲の制限

先ほどのサンプルコードで、対象を整数から浮動小数点数に変更してみましょう。浮動小数点数の入力にはhypothesis.strategies.floatsを使用します。

from hypothesis import given
from hypothesis.strategies import floats

def multiply_zero(num: float):
    return num * 0

@given(floats())
def test_always_zero(s):
    assert multiply_zero(s) == 0.0
$ pytest sample2.py 
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-7.1.2, pluggy-1.0.0
rootdir: ...
plugins: hypothesis-6.47.4
collected 1 item

sample2.py F                                                             [100%]

=================================== FAILURES ===================================
_______________________________ test_always_zero _______________________________

    @given(floats())
>   def test_always_zero(s):

sample2.py:8: 
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ 

s = inf

    @given(floats())
    def test_always_zero(s):
>       assert multiply_zero(s) == 0.0
E       assert nan == 0.0
E        +  where nan = multiply_zero(inf)

sample2.py:9: AssertionError
---------------------------------- Hypothesis ----------------------------------
Falsifying example: test_always_zero(
    s=inf,
)
=========================== short test summary info ============================
FAILED sample2.py::test_always_zero - assert nan == 0.0
============================== 1 failed in 0.28s ===============================

multiply_zeroinfが入力された場合、inf * 0nanとなってテストが失敗しました。ちなみに、入力がnanの場合も失敗します。

この後どうするべきかは、multiply_zeroの本来の仕様やより上流の方針によります。例えば仮に、numinfnanの場合でも強制的に0を返す仕様ならば、無事に実装漏れが見つかったということになります。

この記事では、Hypothesisの機能説明のため、有限の浮動小数点数でテストが成功していれば十分ということにし、入力値自体を制限することにしましょう。

入力値の制限には大きく分けて二つの方法があります。hypothesis.strategiesの機能で生成範囲自体を制約する方法と、生成された値が不都合であれば却下する方法です。前者の方法としては、floats関数の引数でinfnanの扱いを指定できます。後者の場合は、hypothesis.assume関数を利用します。

from math import isnan, isinf

from hypothesis import given, assume
from hypothesis.strategies import floats

# テスト対象
def multiply_zero(num: float):
    return num * 0

# テスト内容
@given(floats(allow_infinity=False, allow_nan=False))
def test_always_zero1(s):
    assert multiply_zero(s) == 0.0

@given(floats())
def test_always_zero2(s):
    assume(not isnan(s))
    assume(not isinf(s))
    assert multiply_zero(s) == 0.0
$ pytest sample3.py 
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-7.1.2, pluggy-1.0.0
rootdir: ...
plugins: hypothesis-6.47.4
collected 2 items                                                              

sample3.py ..                                                            [100%]

============================== 2 passed in 0.39s ===============================

multiply_zeroを変更せずに、全てのテストが通過するようになりました。

テストの入力パターンの作り方

hypothesis.strategiesの主な関数

binary()bytes型の入力。
boolean()bool型の入力。
characters()長さ1のstr型の入力。
dates()datetime.date型の入力。
datetimes()datetime.datetime型の入力。
dictionaries(keys, values)dict型の入力。
floats()float型の入力。
integers()int型の入力。
lists(elements)list型の入力。
none()None
sets(elements)set型の入力。
text(alphabet)str型の入力。
tuples(*args)tuple型の入力。

この他にも色々な関数があります。上のリストにない関数をお探しの場合は、公式ドキュメントのstrategiesに関するページをご覧ください。

入力パターンの例

2つの整数を取る2引数関数。

@given(integers(), integers())
def target_function(x, y):
    ...

2つの整数のタプルを取る1引数関数

@given(tuples(integers(), integers())
def target_function(t):
    x, y = t
    ...

整数のリストを取る1引数関数。

@given(lists(integers))
def target_function(l):
    for elem in l:
        ...

バージョン情報

この記事のコードは以下のバージョンのライブラリで検証しました。

  • Python==3.9.12
  • hypothesis==6.47.4
  • pytest==7.1.2

pytestを使用すると実行ログにライブラリ名およびバージョンを表示してくれます。

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

ITベンチャーでデータ分析、AI開発、システム設計、提案、営業、組織管理、公演、採用などなど多数の役割に従事してきました。

様々な職業や背景の方々と交流するうちに、幅広い分野で問題を解決したり価値を生み出したりするためには、個別の知識だけでなく、汎用的に物事を考える力を伸ばしていく必要があると考えるようになりました。

更に、自分自身の考える力だけでなく、より多くの人々の考える力のトレーニングを応援することで、社会全体を良くしていけるのではないかと考えて、このサイトを作りました。

目次
閉じる