Hypothesisとは何か、プロパティベーステストとは何か
Hypothesisは、Python向けのプロパティベーステストのライブラリである。
プロパティベーステストは、生成された多数の入力データに対してプロパティ(性質)が満たされるかどうかをテストする手法である。
HaskellのQuickCheckライブラリが初出で、現在は各プログラミング言語に移植されている。
従来のユニットテストは、ある程度固定したテストデータを指定してテストを行っていた。
その際、境界値分析などで妥当なパラメータを決定していた。
しかし、境界値分析が必ず通用するとは限らないし、人間が行う以上、ミスも発生する。
プロパティベーステストはデータを固定する代わりにそのデータが満たすプロパティを指定してテストを行う。
実際のテストケースはHypothesisがプロパティを満たすパラメータを決めて生成してくれる。
人力では発見しにくいバグが見つかりやすくなるのだ。
今回は、Hypothesisを使ってDjangoのユニットテストを書くのがこのエントリの主旨である。
また、普段はpytestを使っているので、三者を組み合わせたテストの書き方を調べるのも目的である。
なお、僕は『ロバストPython』でHypothesisの存在を知った。
www.oreilly.co.jp
とても良い本なので、是非読んでみてほしい(監訳しました)。
まずはModelから。
前回のエントリで作成した、説明のためだけに存在するCard
モデルに再登場していただく。
なお、Number
クラスは実際のトランプを意識してサイズアップした。
from django.db import models
class Number(models.IntegerChoices):
ACE = 1
TWO = 2
THREE = 3
FOUR = 4
FIVE = 5
SIX = 6
SEVEN = 7
EIGHT = 8
NINE = 9
TEN = 10
JACK = 11
QUEEN = 12
KING = 13
class Suit(models.TextChoices):
DIAMOND = "♢", "ダイヤ"
SPADE = "♠", "スペード"
HEART = "♡", "ハート"
CLUB = "♣", "クラブ"
class Card(models.Model):
number = models.IntegerField(choices=Number)
suit = models.CharField(max_length=1, choices=Suit)
class Meta:
constraints = [
models.CheckConstraint(check=models.Q(number__in=Number.values), name="number_check"),
models.CheckConstraint(check=models.Q(suit__in=Suit.values), name="suit_check"),
]
def __str__(self) -> str:
return f"{self.suit}{self.number}"
前回書いたユニットテストを再掲する。
import pytest
from django.db.utils import DatabaseError
from ..models import Card, Number, Suit
@pytest.mark.django_db
class TestCardModel:
@pytest.mark.parametrize("number", Number)
@pytest.mark.parametrize("suit", Suit)
def test_card_creation(self, number: Number, suit: Suit):
card = Card.objects.create(number=number, suit=suit)
assert card.number == number
assert card.suit == suit
assert str(card) == f"{suit}{number}"
@pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
def test_invalid_argument(self, number: Number, suit: Suit):
with pytest.raises(DatabaseError):
Card.objects.create(number=number, suit=suit)
Modelのユニットテストを観察してみよう。
- 正常ケースでは、数値およびスートの組み合わせをすべてテストしている。これはテストケースが高々52通りなのですべてチェックできる。
- 失敗ケースでは、数値が不正の例、スートが不正の例をそれぞれ1ケースしかテストできていない。
- 数値の境界値は0と14だが、本当にこれだけだろうか。
- スートとして入る文字列は多数にある。すべての文字などテストできるはずもない。
事前にデータを固定する従来のユニットテストの手法だけでは、想定していない失敗ケースを見過ごしている可能性が生じる。
ここで、プロパティベーステストの出番である。
「想定していない入力が満たすプロパティ」を定義して、実際のテストケースはライブラリに任せよう。
まずは、「想定していないスート」に関するプロパティを考える。
スートの場合は単純で、 今回のケースだと「 ("♢", "♠", "♡", "♣")
以外の文字」である。
また、「想定する数値」に関するプロパティも考えておこう。
こちらも単純で「1から13」である。
これをHypothesisで書くと次のようになる。
import hypothesis
import hypothesis.strategies as st
import pytest
from django.db.utils import DatabaseError
from ..models import Card, Number, Suit
@pytest.mark.django_db
class TestCardModel:
@hypothesis.given(
number=st.integers(min_value=1, max_value=13),
suit=st.characters(exclude_characters=Suit.values) | st.just(None),
)
def test_invalid_suit(self, number: Number, suit: str):
with pytest.raises(DatabaseError):
Card.objects.create(number=number, suit=suit)
ここで着目してほしいのは、@hypothesis.given
デコレータである。
Hypothesisは@hypothesis.given
デコレータでストラテジを指定することから始まる。
st.integers()
は整数を生成するストラテジである。
最大値や最小値はオプションであるが、今回は「1から13」が欲しいので両方指定した。
st.characters()
は長さ1の文字列(つまり文字)を生成するストラテジである。
オプションで細かく制御できるが、今回はSuit.values
を除外した文字列を生成する設定を行った。
これで「 ("♢", "♠", "♡", "♣")
以外の文字」のプロパティが実現できた。
st.just()
は単一の値を生成するプロパティである。
ここで面白いのが、プロパティ同士を|
で連結できることだ。
st.characters(exclude_characters=Suit.values) | st.just(None)
で「("♢", "♠", "♡", "♣")
以外の文字、またはNone
」を満たすプロパティが生成できる。
次に、「想定していない数値」に関するプロパティとそのテストを書く。
@hypothesis.given(
number=st.integers(min_value=14) | st.integers(max_value=0) | st.just(None),
suit=st.sampled_from(Suit.values),
)
def test_invalid_number(self, number: int, suit: Suit):
with pytest.raises(DatabaseError):
Card.objects.create(number=number, suit=suit)
st.sampled_from()
は引数で渡したイテラブルから取得するプロパティである。
小さいイテラブルオブジェクトならこちらが楽である。
st.integers()
単体では「0以下の整数、または14以上の整数」は指定できないので、 |
で連結して実現している。
実際にテストを走らせてみよう。
結果を抜粋する。
E self = <django.db.backends.sqlite3.base.SQLiteCursorWrapper object at 0x000002819441C050>
E query = 'INSERT INTO "sample_card" ("number", "suit") VALUES (?, ?) RETURNING "sample_card"."id"', params = (-9223376507952189107, '♢')
E
E def execute(self, query, params=None):
E if params is None:
E return super().execute(query)
E # Extract names if params is a mapping, i.e. "pyformat" style is used.
E param_names = list(params) if isinstance(params, Mapping) else None
E query = self.convert_query(query, param_names=param_names)
E > return super().execute(query, params)
E E OverflowError: Python int too large to convert to SQLite INTEGER
E
E .tox\test\Lib\site-packages\django\db\backends\sqlite3\base.py:329: OverflowError
トランプの数値として-9223376507952189107
を入れた結果、SQLiteで扱える数値を超えてしまった例外が発生した。
これは境界値分析が足りない例ともいえるし、データベース(今回はSQLite)の制約を想定していないケースを想定していなかったともいえる。
いずれにせよ、従来の固定されたテストケースだけでは発見できないバグがプロパティベーステストで発見できたのは素晴らしいことである。
続いてFormのテストを書いていく。
こちらも前回のエントリからの再登板である。
from django.forms import ModelForm
from .models import Card
class CardModelForm(ModelForm):
class Meta:
model = Card
fields = ["number", "suit"]
従来のユニットテストの手法で書いたテストは以下の通りである。
import pytest
from ..forms import CardForm
from ..models import Number, Suit
@pytest.mark.django_db
class TestCardForm:
@pytest.mark.parametrize("number", Number)
@pytest.mark.parametrize("suit", Suit)
def test_valid_form(self, number: Number, suit: Suit):
form_data = {"number": number, "suit": suit}
form = CardForm(data=form_data)
assert form.is_valid()
card = form.save()
assert card.number == number
assert card.suit == suit
def test_blank_data(self):
form = CardForm(data={})
assert not form.is_valid()
assert len(form.errors) == 2
@pytest.mark.parametrize("number,suit", ((14, Suit.SPADE), (Number.ACE, "★")))
def test_invalid_data(self, number: Number, suit: Suit):
form_data = {"number": number, "suit": suit}
form = CardForm(data=form_data)
assert not form.is_valid()
assert len(form.errors) == 1
@pytest.mark.parametrize("number,suit", ((None, Suit.SPADE), (Number.ACE, None)))
def test_invalid_data_with_none(self, number: Number, suit: Suit):
form_data = {"number": number, "suit": suit}
form = CardForm(data=form_data)
assert not form.is_valid()
assert len(form.errors) == 1
これもまた、Modelのテストケースと同じ問題点を抱えている。
同じようにプロパティベーステストに書き換えてみよう。
import hypothesis
import hypothesis.strategies as st
import pytest
from ..forms import CardModelForm
from ..models import Number, Suit
@pytest.mark.django_db
class TestCardForm:
@hypothesis.given(
number=st.sampled_from(Number.values),
suit=st.sampled_from(Suit.values),
)
def test_valid_form(self, number: Number, suit: Suit):
form_data = {"number": number, "suit": suit}
form = CardModelForm(data=form_data)
assert form.is_valid()
card = form.save()
assert card.number == number
assert card.suit == suit
def test_blank_data(self):
form = CardModelForm(data={})
assert not form.is_valid()
assert len(form.errors) == 2
@hypothesis.given(
number=st.integers(min_value=14) | st.integers(max_value=0) | st.just(None),
suit=st.sampled_from(Suit.values),
)
def test_invalid_number(self, number: int, suit: Suit):
form_data = {"number": number, "suit": suit}
form = CardModelForm(data=form_data)
assert not form.is_valid()
assert len(form.errors) == 1
@hypothesis.given(
number=st.integers(min_value=1, max_value=13),
suit=st.characters(exclude_characters=Suit.values) | st.just(None),
)
def test_invalid_suit(self, number: Number, suit: str):
form_data = {"number": number, "suit": suit}
form = CardModelForm(data=form_data)
assert not form.is_valid()
assert len(form.errors) == 1
元々のサンプルが単純なので、書き換える手間も少ない。
ModelとFormは似たようなテストを行っているが、エラーとなるのはModelの方だけである。
この差はどこにあるのだろうか。
ちなみに、Hypothesisは、実行するたびにテストケースが異なるので、数回テストを実行すると思わぬ発見があるかもしれない。
デフォルトではテストケース1つにつき100通りのテストを生成している。
この値は@hypothesis.settings
デコレータで変更可能だが、生成するテスト数が多すぎるとHypothesisが遅すぎるぞ!とエラーを吐くので注意が必要である。
なお、そのエラーも抑制する方法が存在する。
Viewのテスト
最後に、Viewのテストである。
やはり、前回のエントリから使いまわす。
from django.http.response import HttpResponse
from django.urls import reverse_lazy
from django.views import generic
from .forms import CardModelForm
from .models import Card
class CardListView(generic.ListView):
context_object_name = "card_list"
template_name = "sample/card_list.html"
model = Card
class CardCreateView(generic.FormView):
form_class = CardModelForm
template_name = "sample/card_create.html"
success_url = reverse_lazy("sample:list")
def form_valid(self, form: CardModelForm) -> HttpResponse:
form.save()
return super().form_valid(form)
さて、ここまでの例だけ眺めると、「Hypothesisとpytestを使ってDjangoのユニットテストを書くのは簡単ですね、楽勝だ」と思うかもしれない。
しかし、ViewのテストをHypothesisとpytestを使って書く際に必ず陥る落とし穴が存在する。
pytestの関数スコープのfixtureとHypothesisの相性が悪いのである。
実際、Hypothesisの開発者もそれを認識しているものの、最新版のHypothesisでも同様の事象が発生する。
hypothesis.works
今回の場合、pytest-django
にある client
Fixtureが使えなくなる。
回避方法は複数あるが、client
の場合はおとなしく from django.test import Client
すれば回避できる。
また、Djangoとpytestを組み合わせる際に必ず登場するデコレータ @pytest.mark.django_db
も、Viewのテストでは機能しないのでは、と踏んでいる。
こちらは、 hypothesis.extra.django.TestCase
を使うことで解決できる。
上記の例では@pytest.mark.django_db
を使っているが、Hypothesisを使う場合はhypothesis.extra.django.TestCase
に揃えたほうが良さそうである。
それを踏まえた、落とし穴をすべて塞いだパターンのテストは以下のとおりである。
import hypothesis
import hypothesis.extra.django
import hypothesis.strategies as st
import pytest
from django.test import Client
from django.urls import reverse
from ..models import Card, Number, Suit
from .factory import CardFactory
class TestCardListView(hypothesis.extra.django.TestCase):
@hypothesis.given(
number=st.integers(min_value=1, max_value=13),
suit=st.sampled_from(Suit.values),
)
def test_card_list_view(self, number: int, suit: str):
client = Client()
CardFactory(number=number, suit=suit)
response = client.get(reverse("sample:list"))
assert response.status_code == 200
assert len(response.context["card_list"]) == 1
class TestCardCreateView(hypothesis.extra.django.TestCase):
def test_get_request(self):
client = Client()
response = client.get(reverse("sample:create"))
assert response.status_code == 200
@hypothesis.given(
number=st.integers(min_value=1, max_value=13),
suit=st.sampled_from(Suit.values),
)
def test_valid_post_request(self, number: Number, suit: Suit):
client = Client()
form_data = {"number": number, "suit": suit}
response = client.post(reverse("sample:create"), data=form_data)
assert response.status_code == 302
assert Card.objects.count() == 1
card = Card.objects.first()
assert card.number == number
assert card.suit == suit
assert str(card) == f"{suit}{number}"
@hypothesis.given(
number=st.integers(min_value=14) | st.integers(max_value=0) | st.just(""),
suit=st.sampled_from(Suit.values),
)
def test_invalid_post_by_number(self, number: int, suit: str):
client = Client()
form_data = {"number": number, "suit": suit}
response = client.post(reverse("sample:create"), data=form_data)
assert response.status_code == 200
assert Card.objects.count() == 0
@hypothesis.given(
number=st.integers(min_value=1, max_value=13),
suit=st.characters(exclude_characters=Suit.values) | st.just(""),
)
def test_invalid_post_by_suit(self, number: int, suit: str):
client = Client()
form_data = {"number": number, "suit": suit}
response = client.post(reverse("sample:create"), data=form_data)
assert response.status_code == 200
assert Card.objects.count() == 0
感覚的には、パラメータテストをさらに進化させたような感覚がある。
Hypothesisを使って「想定していませんでした」を減らせると良いなと感じている。
とはいえ、プロパティベーステストは従来のユニットテストを完全に置き換えるものではない。
今回のケースだと、Card
生成の成功パターン、Formに空データを入れるパターンでは不要である。
境界値が曖昧なケースや想定するパターンが複雑なケースで真価を発揮するはずだ。
もちろんフルパワーであなたと戦う気はありませんからご心配なく…
Hypothesisには他にもステートフルテストや凝ったストラテジの生成など、機能がたくさんある。
Djangoの場合、FormのテストをHypothesisで強化するところから始めると導入しやすいかもしれない。
想定していない入力をバンバンHypothesisが生成してくれるだろう。
ちなみに、pytestを実行する際に--hypothesis-show-statistics
オプションをつけると、Hypothesisが生成したテストケースの量などがわかる。
test: commands[0]> pytest --hypothesis-show-statistics --hypothesis-explain .
============================= test session starts ==============================
platform linux -- Python 3.12.3, pytest-8.1.1, pluggy-1.4.0
cachedir: .tox/test/.pytest_cache
Using --randomly-seed=3654462177
django: version: 5.0.4, settings: sampleproject.settings (from ini)
rootdir: /home/runner/work/my_playground/my_playground/django_playground
configfile: pytest.ini
plugins: randomly-3.15.0, Faker-24.11.0, hypothesis-6.100.1, django-4.8.0
collected 12 items
sample/tests/test_models.py ... [ 25%]
sample/tests/test_views.py ..... [ 66%]
sample/tests/test_forms.py .... [100%]
============================ Hypothesis Statistics =============================
sample/tests/test_models.py::TestCardModel::test_invalid_suit:
- during generate phase (0.20 seconds):
- Typical runtimes: ~ 1ms, of which < 1ms in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples
- Stopped because settings.max_examples=100
sample/tests/test_models.py::TestCardModel::test_card_creation:
- during generate phase (0.15 seconds):
- Typical runtimes: ~ 1ms, of which < 1ms in data generation
- 71 passing examples, 0 failing examples, 0 invalid examples
- Stopped because nothing left to do
sample/tests/test_models.py::TestCardModel::test_invalid_number:
- during generate phase (0.20 seconds):
- Typical runtimes: ~ 1ms, of which < 1ms in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples
- Stopped because settings.max_examples=100
sample/tests/test_views.py::TestCardCreateView::test_valid_post_request:
- during generate phase (0.26 seconds):
- Typical runtimes: ~ 3-4 ms, of which < 1ms in data generation
- 56 passing examples, 0 failing examples, 0 invalid examples
- Stopped because nothing left to do
sample/tests/test_views.py::TestCardCreateView::test_invalid_post_by_suit:
- during generate phase (0.81 seconds):
- Typical runtimes: ~ 6-9 ms, of which < 1ms in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples
- Stopped because settings.max_examples=100
sample/tests/test_views.py::TestCardCreateView::test_invalid_post_by_number:
- during generate phase (0.81 seconds):
- Typical runtimes: ~ 6-9 ms, of which < 1ms in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples
- Stopped because settings.max_examples=100
sample/tests/test_views.py::TestCardListView::test_card_list_view:
- during generate phase (0.14 seconds):
- Typical runtimes: ~ 1-2 ms, of which < 1ms in data generation
- 56 passing examples, 0 failing examples, 0 invalid examples
- Stopped because nothing left to do
sample/tests/test_forms.py::TestCardForm::test_invalid_suit:
- during generate phase (0.27 seconds):
- Typical runtimes: ~ 1-2 ms, of which < 1ms in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples
- Stopped because settings.max_examples=100
sample/tests/test_forms.py::TestCardForm::test_invalid_number:
- during generate phase (0.22 seconds):
- Typical runtimes: ~ 1-2 ms, of which < 1ms in data generation
- 100 passing examples, 0 failing examples, 0 invalid examples
- Stopped because settings.max_examples=100
sample/tests/test_forms.py::TestCardForm::test_valid_form:
- during generate phase (0.17 seconds):
- Typical runtimes: ~ 1-2 ms, of which < 1ms in data generation
- 72 passing examples, 0 failing examples, 0 invalid examples
- Stopped because nothing left to do
============================== 12 passed in 4.02s ==============================
test: OK (15.39=setup[9.37]+cmd[6.02] seconds)
congratulations :) (15.45 seconds)
pytestのログだとテスト数が少なく思えるが、実際は各ケースごとに100回テストを行っている(はずだ)。
別の話題:OverflowError: Python int too large to convert to SQLite INTEGER の解決方法
さて、想定していない入力として、巨大な数値(具体的には、int64の範囲を超える整数)が投入されたケースはどうやって直せばよいか。
CheckConstraint
CHECK制約は、データベースに設定するものである。
つまり、チェックする前にSQLiteのINTEGERの範囲を超える整数を投入されたらどうしようもない。
Modelのバリデーション
ModelのFieldにバリデーションを設定できる。
class Card(models.Model):
number = models.IntegerField(
choices=Number,
validators=(
MaxValueValidator(Number.KING.value),
MinValueValidator(Number.ACE.value),
),
)
suit = models.CharField(max_length=1, choices=Suit)
しかし、設定してもバリデーションは効かない。
何故なのか。
その秘密はDjangoのドキュメントに書かれている。
docs.djangoproject.com
雑に要約すると、
- Modelの
full_clean()
メソッドを使うと、モデルに設定してあるバリデーションが実行される。
- ModelFormを使っている場合、
is_valid()
メソッドを実行するとバリデーションが実行される。
Model
のsave()
メソッドを呼んだ際、 full_clean()
や clean()
は自動的に呼ばれない。
つまり、Card.objects.create()
やcard.save()
など、Card
から直接レコードを作成すると、ModelのFieldにバリデーションが短絡されてしまう。
そのため、巨大な数値を投入された際にはCHECK制約しか機能せず、その防御壁も突破されてしまったのだ。
Modelのテストに失敗してFormのテストは通過したのは、この性質によるものだ。
よって、Modelのテストを次のように書き換える。
明示的にfull_clean()
を呼ぶしかない。
import hypothesis
import hypothesis.extra.django
import hypothesis.strategies as st
import pytest
from django.core.validators import ValidationError
from ..models import Card, Number, Suit
class TestCardModel(hypothesis.extra.django.TestCase):
@hypothesis.given(
number=st.integers(min_value=Number.ACE.value, max_value=Number.KING.value),
suit=st.sampled_from(Suit.values),
)
def test_card_creation(self, number: Number, suit: Suit):
card = Card(number=number, suit=suit)
card.full_clean()
card.save()
assert card.number == number
assert card.suit == suit
assert str(card) == f"{suit}{number}"
@hypothesis.given(
number=st.integers(min_value=14) | st.integers(max_value=0) | st.just(None),
suit=st.sampled_from(Suit.values),
)
def test_invalid_number(self, number: int, suit: Suit):
with pytest.raises(ValidationError):
card = Card(number=number, suit=suit)
card.full_clean()
@hypothesis.given(
number=st.integers(min_value=Number.ACE.value, max_value=Number.KING.value),
suit=st.characters(exclude_characters=Suit.values) | st.just(None),
)
def test_invalid_suit(self, number: Number, suit: str):
with pytest.raises(ValidationError):
card = Card(number=number, suit=suit)
card.full_clean()
これで、バリデーションやCHECK制約がすべて機能する状態にできた。
このサンプルコード自体は何も意味のないものだが、HypothesisのおかげでDjangoのModelバリデーションの振る舞いについて少しだけ詳しくなれた。