この記事ではPython Hypothesisを紹介します。
HypothesisはPythonでProperty-based Testingを行うためのライブラリです。
Property-based Testingとは、ある範囲でランダムに生成された多種多様な入力値に対して、テスト対象が一定の特性を常に満たすかどうかをテストする手法です。ごく簡単な例としては次のようなテストです。
テスト対象 | 入力された整数値に0を掛ける関数 |
満たして欲しい特性 | テスト対象の出力値が常に0 |
テスト内容 | ランダムな整数値を関数に入力し、出力を0と比べる、という処理を繰り返す。 |
一般的なテスト(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_zero
にinf
が入力された場合、inf * 0
がnan
となってテストが失敗しました。ちなみに、入力がnan
の場合も失敗します。
この後どうするべきかは、multiply_zero
の本来の仕様やより上流の方針によります。例えば仮に、num
がinf
やnan
の場合でも強制的に0を返す仕様ならば、無事に実装漏れが見つかったということになります。
この記事では、Hypothesisの機能説明のため、有限の浮動小数点数でテストが成功していれば十分ということにし、入力値自体を制限することにしましょう。
入力値の制限には大きく分けて二つの方法があります。hypothesis.strategies
の機能で生成範囲自体を制約する方法と、生成された値が不都合であれば却下する方法です。前者の方法としては、floats
関数の引数でinf
やnan
の扱いを指定できます。後者の場合は、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を使用すると実行ログにライブラリ名およびバージョンを表示してくれます。